Merge remote-tracking branch 'origin/test/tony.cheng/260312' into test/shimai.huang/260309
16 files added
12 files modified
| New file |
| | |
| | | # 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 │ |
| | | │ └──────┘ │ |
| | | │ │ |
| | | └─────────────────────────────────────────────────────────────┘ |
| | | ``` |
| New file |
| | |
| | | # 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. **仅在已接通记录显示按钮**:未接通状态用户无法了解情况,信息不完整 |
| | | |
| | | 选择在调解记录卡片内直接查看的方式,保持用户操作的连贯性和上下文关联。 |
| New file |
| | |
| | | # 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) |
| New file |
| | |
| | | # 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样式与系统整体风格一致 |
| New file |
| | |
| | | # 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调用成功/失败场景测试 |
| | | - 状态转换的正确性验证 |
| | | |
| | | ### 集成测试 |
| | | - 完整的用户操作流程测试 |
| | | - 与现有功能的兼容性测试 |
| | | - 异常场景的恢复能力测试 |
| | | |
| | | ### 用户验收测试 |
| | | - 实际调解场景下的功能验证 |
| | | - 易用性和直观性评估 |
| | | - 性能影响评估 |
| New file |
| | |
| | | # 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. **自动状态检测和恢复**:缺乏人工控制灵活性,可能在不适当的时候触发状态变更 |
| | | |
| | | 选择在现有界面中直接添加按钮的方式,既保持了界面的一致性,又提供了直观的操作方式。 |
| New file |
| | |
| | | # 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) |
| New file |
| | |
| | | # 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检查 |
| | | - 功能在本地开发环境中正常工作 |
| | | - 不引入新的编译警告或错误 |
| | | - 符合现有的代码风格和架构模式 |
| | |
| | | "@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", |
| | |
| | | } |
| | | }, |
| | | "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": { |
| | |
| | | }, |
| | | "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": { |
| | |
| | | }, |
| | | "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": { |
| | |
| | | } |
| | | } |
| | | }, |
| | | "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", |
| | |
| | | "@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", |
| | |
| | | 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; |
| New file |
| | |
| | | /* 录音播放器容器 */ |
| | | .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; |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| New file |
| | |
| | | /* 通话记录弹窗样式 */ |
| | | .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; |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| New file |
| | |
| | | /* 对话列表容器 */ |
| | | .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; |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| New file |
| | |
| | | export { default as CallRecordModal } from './CallRecordModal'; |
| | | export { default as AudioPlayer } from './AudioPlayer'; |
| | | export { default as ConversationList } from './ConversationList'; |
| | |
| | | }; |
| | | 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]); |
| | |
| | | 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 |
| | |
| | | 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; |
| | |
| | | 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调用 |
| | |
| | | }; |
| | | |
| | | /** |
| | | * 渲染状态控制按钮(终止/恢复) |
| | | */ |
| | | 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 ( |
| | |
| | | <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' }}> |
| | |
| | | </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="人工接管确认" |
| | |
| | | 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'; |
| | |
| | | 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; |
| | | |
| | |
| | | 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'; |
| | |
| | | }; |
| | | |
| | | // 获取角色显示名称 |
| | | // 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, |
| | |
| | | {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> |
| | |
| | | </div> |
| | | ))} |
| | | </div> |
| | | |
| | | {/* 通话记录弹窗 */} |
| | | <CallRecordModal |
| | | visible={callRecordVisible} |
| | | onClose={() => setCallRecordVisible(false)} |
| | | record={currentRecord} |
| | | /> |
| | | </> |
| | | ); |
| | | }; |
| | |
| | | }; |
| | | |
| | | export default TabContainer; |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | |
| | | 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 |
| | |
| | | |
| | | // 提取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 || []; |
| | |
| | | |
| | | // 检查终态状态(调解成功/失败/人工接管),终态不执行外呼和存储 |
| | | 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, ',跳过外呼和存储'); |
| | |
| | | // 加载任务时间数据 |
| | | 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 { |
| | |
| | | * 获取通话录音接口 |
| | | * 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} 通话记录 |
| | | */ |
| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | import config from '../config/env'; |
| | | |
| | | // 创建 axios 实例 |
| | | console.log('🔧 Current environment config:', config); |
| | | const service = axios.create({ |
| | | baseURL: config.baseURL, |
| | | timeout: config.timeout, |
| New file |
| | |
| | | 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' |
| | | }) |
| | | ); |
| | | }; |
| | |
| | | 1: '调解中', |
| | | 2: '调解成功', |
| | | 3: '调解失败', |
| | | 4: '人工接管' |
| | | 4: '人工接管', |
| | | 5: 'AI调解已暂停' |
| | | }; |
| | | |
| | | |
| | | return stateMap[state] || '未知状态'; |
| | | }; |
| | | |
| | |
| | | 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== |
| | |
| | | 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== |
| | |
| | | |
| | | 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" |
| | |
| | | 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" |
| | |
| | | |
| | | 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" |