feat: 添加通话记录查看器功能模块
- 新增通话记录查看器组件集(AudioPlayer、CallRecordModal、ConversationList)
- 更新TabContainer以支持新功能tab
- 修改API服务和请求配置以支持通话记录相关接口
- 添加代理配置文件setupProxy.js
- 更新依赖包配置(package.json、yarn.lock)
- 添加设计文档和规范说明
12 files added
7 files modified
| | |
| | | <i class="fas fa-user-tie"></i> |
| | | 人工接管 |
| | | </button> |
| | | <button class="floating-control-btn" id="floatingStopBtn"> |
| | | <i class="fas fa-user-tie"></i> |
| | | 人工接管 |
| | | </button> |
| | | </div> |
| | | </div> |
| | | |
| 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样式与系统整体风格一致 |
| | |
| | | "@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", |
| 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'; |
| | |
| | | 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; |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | |
| | | * 获取通话录音接口 |
| | | * 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; |
| | |
| | | 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' |
| | | }) |
| | | ); |
| | | }; |
| | |
| | | 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" |