From 6f344a5292739e21d0c8f06c346be44c31c38552 Mon Sep 17 00:00:00 2001
From: tony.cheng <chengmingwei_1984122@126.com>
Date: Wed, 11 Feb 2026 10:54:23 +0800
Subject: [PATCH] 修复智能外呼气泡组件变量初始化错误和时间格式问题

---
 openspec/changes/integrate-auto-outbound-call/design.md |  243 ++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 243 insertions(+), 0 deletions(-)

diff --git a/openspec/changes/integrate-auto-outbound-call/design.md b/openspec/changes/integrate-auto-outbound-call/design.md
new file mode 100644
index 0000000..ff4da5f
--- /dev/null
+++ b/openspec/changes/integrate-auto-outbound-call/design.md
@@ -0,0 +1,243 @@
+# Design: 智能外呼自动触发与状态监控
+
+## Context
+
+当前"云小调"系统已实现首页案件数据加载和右下角智能外呼气泡组件,但外呼流程需要手动触发,缺少自动化能力。根据产品需求,调解流程应在案件数据加载完成后自动启动 AI 智能外呼,提升调解效率。
+
+**核心挑战**:
+1. 何时触发外呼?如何避免重复触发?
+2. 如何处理多人外呼场景(申请人、被申请人)?
+3. 如何管理 jobId 状态和生命周期?
+4. 如何设计轮询策略平衡实时性与服务器压力?
+5. 如何处理页面刷新、组件卸载等边界场景?
+
+**技术栈约束**:
+- React + Ant Design 4.24.12
+- Context API 管理全局状态
+- localStorage 持久化存储
+- 前端 Mock 数据(当前阶段)
+
+## Goals / Non-Goals
+
+### Goals
+1. **自动触发**:案件数据加载完成后自动发起外呼,无需手动操作
+2. **状态监控**:实时展示外呼状态(拨号中、通话中、已完成等)
+3. **多任务支持**:同时展示多人通话状态
+4. **幂等性保证**:页面刷新时不重复发起外呼,继续监听现有任务
+5. **自动清理**:通话结束后自动移除气泡和存储数据
+6. **错误容错**:API 失败时提供友好提示,不阻塞主流程
+
+### Non-Goals
+- 不涉及后端外呼接口实现
+- 不涉及通话录音播放功能
+- 不涉及外呼任务的手动取消或重试操作
+- 不涉及复杂的权限控制(当前仅前端功能)
+
+## Decisions
+
+### Decision 1: 触发时机选择 - 在 `loadCaseData` 后触发
+
+**方案对比**:
+
+| 方案 | 优点 | 缺点 | 决策 |
+|------|------|------|------|
+| A. 在 `CaseDataContext` 中触发 | 集中管理数据流,逻辑清晰 | 增加 Context 耦合度 | ✅ 采用 |
+| B. 在 `OutboundCallWidget` 组件内触发 | 解耦数据和外呼逻辑 | 依赖组件挂载时机,可能触发延迟 | ❌ 不采用 |
+| C. 在 App.js 路由层触发 | 全局控制 | 难以获取案件数据,需要额外传递 | ❌ 不采用 |
+
+**选择 A 的理由**:
+- `loadCaseData` 是唯一的数据入口,在此处触发可确保数据就绪
+- 避免组件挂载时机不确定导致的触发延迟
+- 便于实现幂等性检查(基于 localStorage 判断是否已有活跃任务)
+
+### Decision 2: jobId 状态管理 - localStorage + 活跃状态过滤
+
+**数据结构设计**:
+```javascript
+// localStorage key: 'outbound_call_jobs'
+[
+  {
+    jobId: "1770602736933-4794-83bb-e2cec968ebfc",
+    callStatus: "Scheduling", // 或 Executing, Paused, Drafted, Succeeded, Failed, Cancelled
+    personId: "2303191513081131",
+    mediationId: 20,
+    startTime: 1738228800000, // 触发时间戳
+    retryCount: 0, // 重试次数
+    pollStartTime: 1738228800000 // 轮询开始时间(用于2小时超时检测)
+  }
+]
+```
+
+**状态分类**:
+- **活跃状态**(需轮询):`Scheduling`, `Executing`, `Paused`, `Drafted`
+- **终态**(需清除):`Succeeded`, `Failed`, `Cancelled`
+
+**方案对比**:
+
+| 方案 | 优点 | 缺点 | 决策 |
+|------|------|------|------|
+| A. localStorage 持久化 | 页面刷新后状态不丢失,支持断点续传 | 需要手动清理终态数据 | ✅ 采用 |
+| B. Context 内存存储 | 简单,自动清理 | 页面刷新后丢失,无法继续监听 | ❌ 不采用 |
+| C. IndexedDB 存储 | 支持复杂查询 | 过度设计,增加复杂度 | ❌ 不采用 |
+
+**选择 A 的理由**:
+- 用户刷新页面后可继续监听外呼状态,提升体验
+- 终态清理逻辑简单,可在轮询中实现
+
+### Decision 3: 轮询策略 - 10秒间隔 + 2小时超时 + 10次重试
+
+**参数配置**:
+```javascript
+const POLL_INTERVAL = 10000; // 10秒
+const MAX_POLL_DURATION = 7200000; // 2小时(毫秒)
+const MAX_RETRY_COUNT = 10; // 最大重试次数
+```
+
+**方案对比**:
+
+| 方案 | 轮询间隔 | 服务器压力 | 实时性 | 决策 |
+|------|----------|-----------|--------|------|
+| A. 5秒间隔 | 5s | 较高 | 高 | ❌ 压力过大 |
+| B. 10秒间隔 | 10s | 中等 | 较高 | ✅ 采用 |
+| C. 30秒间隔 | 30s | 低 | 低 | ❌ 延迟过高 |
+
+**选择 B 的理由**:
+- 10秒间隔在实时性和服务器压力之间取得平衡
+- 对于电话外呼场景,10秒延迟可接受(通话时长通常数分钟)
+
+**超时设计**:
+- 2小时后自动停止轮询,避免无限轮询
+- 超时后从 localStorage 移除 jobId,防止脏数据累积
+
+**重试机制**:
+- 单次查询失败累加 `retryCount`,不立即清除 jobId
+- 连续失败 10 次后展示错误提示并清除该 jobId
+- 避免因偶发网络抖动导致任务丢失
+
+### Decision 4: 多任务展示 - 纵向堆叠气泡
+
+**UI 设计**:
+```
+┌─────────────────────────┐
+│ 智能体工作中             │
+│ 正在与申请人(张三)通话... │
+│ 已持续: 02:35           │
+└─────────────────────────┘
+┌─────────────────────────┐
+│ 智能体工作中             │
+│ 正在与被申请人(李四)通话...│
+│ 已持续: 01:20           │
+└─────────────────────────┘
+```
+
+**方案对比**:
+
+| 方案 | 优点 | 缺点 | 决策 |
+|------|------|------|------|
+| A. 纵向堆叠多个气泡 | 清晰展示每个任务,易扩展 | 占用屏幕空间 | ✅ 采用 |
+| B. 单个气泡轮播展示 | 节省空间 | 用户难以同时了解所有任务 | ❌ 不采用 |
+| C. 折叠列表 | 灵活 | 增加交互复杂度 | ❌ 不采用 |
+
+**选择 A 的理由**:
+- 外呼任务数量有限(通常 1-2 个),堆叠不会占用过多空间
+- 直观展示每个任务状态,符合用户心智模型
+
+### Decision 5: 幂等性设计 - 基于活跃 jobId 判断
+
+**触发条件**:
+```javascript
+// 伪代码
+const activeJobs = getActiveJobsFromStorage(); // 从 localStorage 获取活跃任务
+if (activeJobs.length > 0) {
+  console.log('已有活跃外呼任务,跳过发起新外呼');
+  return;
+}
+// 否则,发起新外呼
+await OutboundBotAPIService.makeCallV2(...);
+```
+
+**边界场景处理**:
+1. **首次进入页面**:无活跃 jobId,发起新外呼
+2. **刷新页面**:检测到活跃 jobId,继续监听,不发起新外呼
+3. **通话结束后再次刷新**:jobId 已清除,可发起新外呼
+4. **多标签页同时打开**:共享 localStorage,避免重复触发
+
+## Risks / Trade-offs
+
+### Risk 1: 轮询频率与服务器压力
+
+**风险描述**:多个用户同时轮询可能增加服务器负载。
+
+**缓解措施**:
+- 采用 10 秒轮询间隔(非 5 秒或更短)
+- 设置 2 小时最大轮询时长,避免无限轮询
+- 终态任务立即停止轮询,减少无效请求
+
+**Trade-off**:牺牲部分实时性(10秒延迟)换取服务器稳定性。
+
+### Risk 2: localStorage 容量限制
+
+**风险描述**:jobId 数据累积可能超出 localStorage 容量(5MB)。
+
+**缓解措施**:
+- 终态任务立即清除
+- 定期清理超过 24 小时的历史数据(后续扩展)
+- 单个 jobId 对象约 200 字节,可存储数千条记录
+
+**Trade-off**:当前风险较低,暂不引入复杂的存储淘汰策略。
+
+### Risk 3: 页面刷新时的竞态条件
+
+**风险描述**:用户快速刷新页面可能触发多次 `makeCallV2` 调用。
+
+**缓解措施**:
+- 在 `CaseDataContext` 中使用 `hasLoaded` 标志防止重复加载
+- `makeCallV2` 调用前检查活跃 jobId,确保幂等性
+- 后端接口应支持幂等性设计(基于 caseId + mediationId 去重)
+
+**Trade-off**:前后端协同保障,前端尽最大努力避免重复调用。
+
+### Risk 4: 组件卸载时的内存泄漏
+
+**风险描述**:定时器未清理可能导致内存泄漏或组件卸载后仍执行回调。
+
+**缓解措施**:
+- 在 `useEffect` 返回清理函数:`return () => clearInterval(intervalId)`
+- 使用 `useRef` 保存 `isMounted` 状态,回调中检查组件是否已卸载
+- 考虑使用 `AbortController` 取消未完成的 API 请求
+
+**Trade-off**:增加代码复杂度,但避免生产环境内存泄漏和控制台报错。
+
+## Migration Plan
+
+### Phase 1: 实现基础功能(当前提案)
+1. 在 `CaseDataContext` 中添加外呼触发逻辑
+2. 改造 `OutboundCallWidget` 轮询逻辑
+3. 实现 localStorage 状态管理
+4. 完成错误处理和重试机制
+
+### Phase 2: 优化与扩展(后续迭代)
+1. 支持手动取消外呼任务
+2. 外呼结果通知(成功/失败弹窗)
+3. 外呼历史记录查询
+4. 集成通话录音播放功能
+
+### Rollback Plan
+如发现严重问题,可通过以下步骤回滚:
+1. 注释 `CaseDataContext` 中外呼触发代码
+2. 恢复 `OutboundCallWidget` 原有轮询逻辑
+3. 清除 localStorage 中 `outbound_call_jobs` 数据
+
+## Open Questions
+
+1. **多标签页同步问题**:如何在多个标签页之间同步 jobId 状态?
+   - **初步方案**:使用 `window.addEventListener('storage', ...)` 监听 localStorage 变化
+   
+2. **外呼失败后是否自动重试**:当前设计为不自动重试,是否需要调整?
+   - **决策点**:需产品确认,当前保持简单设计
+   
+3. **通话时长计算逻辑**:API 返回的 `startTime` 字段格式是否统一?
+   - **待确认**:API 文档中未明确说明,需要与后端对齐
+
+4. **外呼任务优先级**:如果同时有多个案件,如何决定外呼顺序?
+   - **当前方案**:按 API 响应顺序依次展示,不涉及优先级排序

--
Gitblit v1.8.0