| | |
| | | import React, { useState, useEffect, useCallback, useRef } from 'react'; |
| | | import { message } from 'antd'; |
| | | import { useCaseData } from '../../contexts/CaseDataContext'; |
| | | import OutboundBotAPIService from '../../services/OutboundBotAPIService'; |
| | | import { message } from 'antd'; |
| | | |
| | | // 常量配置 |
| | | const OUTBOUND_JOBS_KEY = 'outbound_call_jobs'; |
| | | const POLL_INTERVAL = 10000; // 10秒轮询间隔 |
| | | const MAX_POLL_DURATION = 7200000; // 2小时最大轮询时长(毫秒) |
| | | const MAX_RETRY_COUNT = 10; // 最大重试次数 |
| | | |
| | | // 活跃状态和终态定义 |
| | | const ACTIVE_STATUSES = ['Scheduling', 'Executing', 'Paused', 'Drafted']; |
| | | const TERMINAL_STATUSES = ['Succeeded', 'Failed', 'Cancelled']; |
| | | // 活跃状态列表 |
| | | const ACTIVE_STATUSES = ['Scheduling', 'InProgress', 'Calling', 'Ringing', 'Answered']; |
| | | |
| | | // 状态中文映射 |
| | | const STATUS_MAP = { |
| | | 'Scheduling': '拨号中', |
| | | 'Executing': '通话中', |
| | | 'Succeeded': '通话成功', |
| | | 'Paused': '暂停', |
| | | 'Failed': '通话失败', |
| | | 'Cancelled': '通话已取消', |
| | | 'Drafted': '草稿' |
| | | }; |
| | | // Scheduling 状态 - 此状态变化不需要调用更新API |
| | | const SCHEDULING_STATUS = 'Scheduling'; |
| | | |
| | | /** |
| | | * 智能外呼通话显示组件 |
| | | * 基于 localStorage 中的 jobId 轮询查询通话状态 |
| | | * 支持多任务并行显示、自动清理终态任务 |
| | | * 显示在页面右下角的气泡组件,支持多人通话 |
| | | * @param {Object} props |
| | | * @param {Function} props.onSwitchTab - Tab切换回调 |
| | | * @param {Function} props.onRefreshData - 数据刷新回调 |
| | | */ |
| | | const OutboundCallWidget = () => { |
| | | const [isVisible, setIsVisible] = useState(true); |
| | | const [isMinimized, setIsMinimized] = useState(false); |
| | | const OutboundCallWidget = ({ onSwitchTab, onRefreshData }) => { |
| | | const { caseData } = useCaseData(); |
| | | const [isVisible, setIsVisible] = useState(false); // 默认隐藏 |
| | | const [isMinimized, setIsMinimized] = useState(false); // 默认展开(非最小化) |
| | | const [calls, setCalls] = useState([]); |
| | | const isMountedRef = useRef(true); |
| | | |
| | | // 轮询间隔(毫秒) |
| | | const POLL_INTERVAL = 10000; // 10秒 |
| | | |
| | | // 最大重试次数 |
| | | const MAX_RETRY_COUNT = 10; |
| | | |
| | | /** |
| | | * 格式化通话时长 |
| | | * @param {number} startTime - 开始时间戳(毫秒) |
| | | * @returns {string} 格式化的时长(MM:SS) |
| | | */ |
| | | const formatDuration = (startTime) => { |
| | | if (!startTime) return '00:00'; |
| | | const seconds = Math.floor((Date.now() - startTime) / 1000); |
| | | // 获取 caseId |
| | | const caseId = caseData?.caseId || caseData?.case_id; |
| | | |
| | | // 格式化通话时长 |
| | | const formatDuration = (seconds) => { |
| | | const mins = Math.floor(seconds / 60); |
| | | const secs = seconds % 60; |
| | | return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; |
| | | }; |
| | | |
| | | /** |
| | | * 从 localStorage 读取外呼任务 |
| | | * 从 localStorage 读取外呼任务(包括成功和失败的任务) |
| | | * @returns {Array} 任务数组 |
| | | */ |
| | | const loadJobsFromStorage = () => { |
| | | try { |
| | | const stored = localStorage.getItem(OUTBOUND_JOBS_KEY); |
| | | if (!stored) return []; |
| | | return JSON.parse(stored); |
| | | // 读取成功的任务 |
| | | const storedSuccess = localStorage.getItem(OUTBOUND_JOBS_KEY); |
| | | const successJobs = storedSuccess ? JSON.parse(storedSuccess) : []; |
| | | |
| | | // 读取失败的任务 |
| | | const storedFailed = localStorage.getItem(`${OUTBOUND_JOBS_KEY}_failed`); |
| | | const failedJobs = storedFailed ? JSON.parse(storedFailed) : []; |
| | | console.log('读取失败任务:', failedJobs); |
| | | |
| | | // 清理失败任务 - 按 errorCode 不同策略 |
| | | const now = Date.now(); |
| | | const cleanedFailedJobs = failedJobs.filter(job => { |
| | | // errorCode: 1001 - 超过 startTime 就清理 |
| | | if (job.errorCode === 1001) { |
| | | const jobStartTime = typeof job.startTime === 'string' ? new Date(job.startTime).getTime() : job.startTime; |
| | | return now < jobStartTime; // 当前时间小于 startTime 才保留 |
| | | } |
| | | |
| | | // errorCode: 1002 - 跨天清理 (比较日期是否不同) |
| | | if (job.errorCode === 1002) { |
| | | const startDate = new Date(job.startTime); |
| | | const nowDate = new Date(now); |
| | | |
| | | // 格式化为 YYYY-MM-DD 进行比较 |
| | | const startDateString = startDate.getFullYear() + '-' + |
| | | String(startDate.getMonth() + 1).padStart(2, '0') + '-' + |
| | | String(startDate.getDate()).padStart(2, '0'); |
| | | |
| | | const nowDateString = nowDate.getFullYear() + '-' + |
| | | String(nowDate.getMonth() + 1).padStart(2, '0') + '-' + |
| | | String(nowDate.getDate()).padStart(2, '0'); |
| | | |
| | | // 如果日期相同,保留;如果日期不同,清理 |
| | | return startDateString === nowDateString; |
| | | } |
| | | |
| | | // 其他 errorCode 使用默认 24 小时策略 |
| | | return (now - job.startTime) < 24 * 60 * 60 * 1000; |
| | | }); |
| | | |
| | | // 如果清理后数量变化,更新 localStorage |
| | | if (cleanedFailedJobs.length !== failedJobs.length) { |
| | | localStorage.setItem(`${OUTBOUND_JOBS_KEY}_failed`, JSON.stringify(cleanedFailedJobs)); |
| | | } |
| | | |
| | | // 按 personId 去重失败任务 |
| | | const uniqueFailedJobs = []; |
| | | const seenPersonIds = new Set(); |
| | | cleanedFailedJobs.forEach(job => { |
| | | if (!seenPersonIds.has(job.personId)) { |
| | | seenPersonIds.add(job.personId); |
| | | uniqueFailedJobs.push(job); |
| | | } |
| | | }); |
| | | |
| | | // 合并所有任务 |
| | | return [...successJobs, ...uniqueFailedJobs]; |
| | | } catch (err) { |
| | | console.error('读取外呼任务失败:', err); |
| | | return []; |
| | |
| | | }; |
| | | |
| | | /** |
| | | * 批量更新通话状态到后端 |
| | | * @param {Array} jobsToUpdate - 需要更新的任务列表 |
| | | * @returns {Promise<boolean>} 是否有成功的更新 |
| | | */ |
| | | const updateCallStatusToBackend = async (jobsToUpdate) => { |
| | | if (!jobsToUpdate || jobsToUpdate.length === 0) return false; |
| | | |
| | | try { |
| | | // 并行调用所有任务的更新API |
| | | const results = await Promise.all( |
| | | jobsToUpdate.map(async (job) => { |
| | | try { |
| | | await OutboundBotAPIService.updateCallStatus({ |
| | | jobId: job.jobId, |
| | | callStatus: job.newStatus |
| | | }); |
| | | console.log(`状态更新成功: ${job.jobId} -> ${job.newStatus}`); |
| | | return { success: true, job }; |
| | | } catch (err) { |
| | | console.error(`状态更新失败: ${job.jobId}`, err); |
| | | return { success: false, job }; |
| | | } |
| | | }) |
| | | ); |
| | | |
| | | // 检查是否有成功的更新 |
| | | const hasSuccess = results.some(r => r.success); |
| | | return hasSuccess; |
| | | } catch (err) { |
| | | console.error('批量更新状态失败:', err); |
| | | return false; |
| | | } |
| | | }; |
| | | |
| | | /** |
| | | * 触发页面更新(刷新数据 + 切换Tab) |
| | | */ |
| | | const triggerPageUpdate = useCallback(() => { |
| | | // 刷新案件数据 |
| | | if (onRefreshData) { |
| | | onRefreshData(); |
| | | } |
| | | // 切换到AI调解实时看板 |
| | | if (onSwitchTab) { |
| | | onSwitchTab('mediation-board'); |
| | | } |
| | | }, [onRefreshData, onSwitchTab]); |
| | | |
| | | /** |
| | | * 移除终态或超时的任务 |
| | | * @param {Array} jobs - 当前任务数组 |
| | | * @returns {Array} 清理后的任务数组 |
| | |
| | | const cleanupJobs = (jobs) => { |
| | | const now = Date.now(); |
| | | return jobs.filter(job => { |
| | | // 检查是否超时(2小时) |
| | | if (now - job.pollStartTime > MAX_POLL_DURATION) { |
| | | console.warn('外呼轮询超时(2小时),自动停止,jobId:', job.jobId); |
| | | return false; |
| | | // 检查是否为活跃状态 |
| | | if (ACTIVE_STATUSES.includes(job.callStatus)) { |
| | | // 检查是否超时(2小时) |
| | | const elapsed = now - (job.pollStartTime || job.startTime || now); |
| | | if (elapsed > 2 * 60 * 60 * 1000) { |
| | | console.warn('外呼轮询超时(2小时),自动停止,jobId:', job.jobId); |
| | | return false; |
| | | } |
| | | return true; |
| | | } |
| | | // 保留活跃状态 |
| | | return ACTIVE_STATUSES.includes(job.callStatus); |
| | | return false; |
| | | }); |
| | | }; |
| | | |
| | | /** |
| | | * 查询通话状态(轮询核心逻辑) |
| | | * 查询通话状态 |
| | | */ |
| | | const fetchCallStatus = useCallback(async () => { |
| | | let jobs = loadJobsFromStorage(); |
| | | // 从 localStorage 读取任务 |
| | | const storedJobs = loadJobsFromStorage(); |
| | | |
| | | // 过滤出活跃任务 |
| | | const activeJobs = jobs.filter(job => ACTIVE_STATUSES.includes(job.callStatus)); |
| | | // 分离成功任务和失败任务 |
| | | const successJobs = storedJobs.filter(job => !job.errorCode && ACTIVE_STATUSES.includes(job.callStatus)); |
| | | const failedJobs = storedJobs.filter(job => job.errorCode > 0); |
| | | |
| | | if (activeJobs.length === 0) { |
| | | setCalls([]); |
| | | if (successJobs.length === 0) { |
| | | // 没有活跃任务,更新状态并返回 |
| | | if (isMountedRef.current) { |
| | | setCalls([...failedJobs]); |
| | | if (failedJobs.length > 0 && !isVisible) { |
| | | setIsVisible(true); |
| | | } |
| | | } |
| | | return; |
| | | } |
| | | |
| | | console.log('轮询查询通话状态,任务数量:', activeJobs.length); |
| | | // 收集需要更新到后端的任务(状态变化且原状态不是Scheduling) |
| | | const jobsNeedBackendUpdate = []; |
| | | |
| | | // 遍历所有活跃任务,逐个查询状态(caseRef 和 jobId 都是必传参数) |
| | | // 并行查询所有任务的状态 |
| | | const updatedJobs = await Promise.all( |
| | | activeJobs.map(async (job) => { |
| | | successJobs.map(async (job) => { |
| | | try { |
| | | // 同时传入 caseRef 和 jobId |
| | | const response = await OutboundBotAPIService.getCallStatus({ |
| | | const response = await OutboundBotAPIService.getCallStatus({ |
| | | caseRef: job.caseId, |
| | | jobId: job.jobId |
| | | phoneNumber: job.phoneNumber, |
| | | jobId: job.jobId |
| | | }); |
| | | |
| | | |
| | | if (response?.data) { |
| | | const newStatus = response.data.callStatus; |
| | | |
| | | // 更新任务状态 |
| | | const updatedJob = { |
| | | ...job, |
| | | callStatus: newStatus, |
| | | retryCount: 0 // 成功后重置重试计数 |
| | | }; |
| | | |
| | | // 检测终态 |
| | | if (TERMINAL_STATUSES.includes(newStatus)) { |
| | | console.log('检测到终态,jobId:', job.jobId, ', status:', newStatus); |
| | | return null; // 标记为删除 |
| | | // 如果状态发生变化,更新任务 |
| | | if (newStatus !== job.callStatus) { |
| | | console.log(`任务 ${job.jobId} 状态更新: ${job.callStatus} -> ${newStatus}`); |
| | | |
| | | // 检查是否需要调用后端更新API(排除Scheduling状态) |
| | | if (job.callStatus !== SCHEDULING_STATUS) { |
| | | jobsNeedBackendUpdate.push({ |
| | | ...job, |
| | | newStatus |
| | | }); |
| | | } |
| | | |
| | | // 如果是终态,可以从轮询中移除 |
| | | if (!ACTIVE_STATUSES.includes(newStatus)) { |
| | | console.log(`任务 ${job.jobId} 达到终态: ${newStatus}`); |
| | | return null; // 标记为删除 |
| | | } |
| | | |
| | | return { |
| | | ...job, |
| | | callStatus: newStatus, |
| | | pollStartTime: Date.now(), // 重置超时计时 |
| | | retryCount: 0 // 重置重试计数 |
| | | }; |
| | | } |
| | | |
| | | return updatedJob; |
| | | |
| | | // 状态未变化,保留原任务 |
| | | return job; |
| | | } |
| | | |
| | | // API 返回空数据,保留原任务 |
| | | return job; |
| | | } catch (err) { |
| | | console.warn('查询失败,重试次数:', job.retryCount + 1, '/', MAX_RETRY_COUNT, ', jobId:', job.jobId, ', 错误:', err.message); |
| | | console.error('获取通话状态失败:', err); |
| | | |
| | | // 累加重试计数 |
| | | const retryCount = job.retryCount + 1; |
| | |
| | | // 清理超时任务 |
| | | const cleanedJobs = cleanupJobs(filteredJobs); |
| | | |
| | | // 如果有需要更新到后端的任务,批量调用更新API |
| | | if (jobsNeedBackendUpdate.length > 0) { |
| | | const hasUpdateSuccess = await updateCallStatusToBackend(jobsNeedBackendUpdate); |
| | | |
| | | // 如果有成功的更新,触发页面更新 |
| | | if (hasUpdateSuccess) { |
| | | triggerPageUpdate(); |
| | | } |
| | | } |
| | | |
| | | // 保存到 localStorage |
| | | saveJobsToStorage(cleanedJobs); |
| | | |
| | | // 合并成功任务和失败任务 |
| | | const allJobs = [...cleanedJobs, ...failedJobs]; |
| | | |
| | | // 更新组件状态 |
| | | if (isMountedRef.current) { |
| | | setCalls(cleanedJobs); |
| | | setCalls(allJobs); |
| | | // 如果有任务,显示气泡 |
| | | if (allJobs.length > 0 && !isVisible) { |
| | | setIsVisible(true); |
| | | } |
| | | } |
| | | }, []); |
| | | }, [isVisible, triggerPageUpdate]); |
| | | |
| | | // 定时轮询通话状态 |
| | | useEffect(() => { |
| | |
| | | }; |
| | | }, [fetchCallStatus]); |
| | | |
| | | // 组件挂载时标记 |
| | | useEffect(() => { |
| | | isMountedRef.current = true; |
| | | return () => { |
| | | isMountedRef.current = false; |
| | | }; |
| | | }, []); |
| | | |
| | | // 关闭气泡 |
| | | const handleClose = (e) => { |
| | | e.stopPropagation(); |
| | |
| | | setIsVisible(true); |
| | | }; |
| | | |
| | | // 过滤掉已过期的任务(用于气泡显示) |
| | | const now = Date.now(); |
| | | const activeCalls = calls.filter(call => { |
| | | // errorCode: 1001 - 超过 startTime 视为过期 |
| | | if (call.errorCode === 1001) { |
| | | const callStartTime = typeof call.startTime === 'string' ? new Date(call.startTime).getTime() : call.startTime; |
| | | return now < callStartTime; // 当前时间小于 startTime 才显示 |
| | | } |
| | | // errorCode: 1002 - 跨天视为过期 |
| | | if (call.errorCode === 1002) { |
| | | const startDate = new Date(call.startTime); |
| | | const nowDate = new Date(now); |
| | | const startDateString = startDate.getFullYear() + '-' + |
| | | String(startDate.getMonth() + 1).padStart(2, '0') + '-' + |
| | | String(startDate.getDate()).padStart(2, '0'); |
| | | const nowDateString = nowDate.getFullYear() + '-' + |
| | | String(nowDate.getMonth() + 1).padStart(2, '0') + '-' + |
| | | String(nowDate.getDate()).padStart(2, '0'); |
| | | return startDateString === nowDateString; // 同一天才显示 |
| | | } |
| | | // 其他状态的任务正常显示 |
| | | return true; |
| | | }); |
| | | |
| | | // 如果最小化,显示AI客服图标 |
| | | if (isMinimized) { |
| | | |
| | | |
| | | return ( |
| | | <div |
| | | onClick={handleExpand} |
| | |
| | | }} |
| | | /> |
| | | {/* 红点提示有通话 */} |
| | | {calls.length > 0 && ( |
| | | {activeCalls.length > 0 && ( |
| | | <div |
| | | style={{ |
| | | position: 'absolute', |
| | |
| | | fontWeight: 'bold', |
| | | }} |
| | | > |
| | | {calls.length} |
| | | {activeCalls.length} |
| | | </div> |
| | | )} |
| | | </div> |
| | |
| | | maxWidth: 320, |
| | | }} |
| | | > |
| | | {calls.map((call, index) => ( |
| | | {activeCalls.map((call, index) => ( |
| | | <div |
| | | key={call.jobId || call.phoneNumber || index} |
| | | style={{ |
| | |
| | | width: 8, |
| | | height: 8, |
| | | borderRadius: '50%', |
| | | background: '#52c41a', |
| | | background: call.errorCode > 0 ? '#ff4d4f' : '#52c41a', |
| | | animation: 'pulse 2s infinite', |
| | | }} |
| | | /> |
| | |
| | | {/* 通话信息 */} |
| | | <div style={{ marginBottom: 10 }}> |
| | | <div style={{ fontSize: 14, opacity: 0.9, lineHeight: 1.5 }}> |
| | | 正在与 {call.personId || '未知联系人'} 电话沟通中... |
| | | </div> |
| | | <div style={{ fontSize: 12, opacity: 0.75, marginTop: 4 }}> |
| | | 状态:{STATUS_MAP[call.callStatus] || call.callStatus} |
| | | {call.errorCode > 0 ? ( |
| | | // 失败任务显示 |
| | | <span> |
| | | {call.perClassName || '联系人'} |
| | | {call.trueName && `(${call.trueName})`}: |
| | | {call.message} |
| | | </span> |
| | | ) : ( |
| | | // 成功任务显示 |
| | | <span> |
| | | 正在与{call.perClassName || '申请方'}({call.trueName || call.personId})电话沟通中... |
| | | </span> |
| | | )} |
| | | </div> |
| | | </div> |
| | | |
| | | {/* 通话时长 */} |
| | | <div |
| | | style={{ |
| | | display: 'flex', |
| | | alignItems: 'center', |
| | | gap: 6, |
| | | fontSize: 13, |
| | | opacity: 0.85, |
| | | }} |
| | | > |
| | | <i className="far fa-clock" /> |
| | | <span>已持续: {formatDuration(call.startTime)}</span> |
| | | </div> |
| | | {/* 通话时长(仅对成功任务显示)*/} |
| | | {!call.errorCode && call.pollStartTime && ( |
| | | <div |
| | | style={{ |
| | | display: 'flex', |
| | | alignItems: 'center', |
| | | gap: 6, |
| | | fontSize: 13, |
| | | opacity: 0.85, |
| | | }} |
| | | > |
| | | <i className="far fa-clock" /> |
| | | <span>已持续: {formatDuration(Math.floor((Date.now() - call.pollStartTime) / 1000))}</span> |
| | | </div> |
| | | )} |
| | | </div> |
| | | ))} |
| | | |
| | | {/* 无通话时的占位提示 */} |
| | | {calls.length === 0 && isVisible && ( |
| | | {activeCalls.length === 0 && isVisible && ( |
| | | <div |
| | | style={{ |
| | | background: 'linear-gradient(135deg, #1A6FB8 0%, #0d4a8a 100%)', |
| | |
| | | ); |
| | | }; |
| | | |
| | | export default OutboundCallWidget; |
| | | export default OutboundCallWidget; |