1 files added
4 files modified
| | |
| | | </ToolModal> |
| | | )} |
| | | |
| | | {/* 智能外呼通话显示组件 - 全局显示 */} |
| | | {/* 智能外呼通话显示组件 - 默认隐藏,可主动触发显示 */} |
| | | <OutboundCallWidget /> |
| | | </div> |
| | | </Spin> |
| | |
| | | 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 STATUS_MAP = { |
| | | 'Scheduling': '拨号中', |
| | | 'Executing': '通话中', |
| | | 'Succeeded': '通话成功', |
| | | 'Paused': '暂停', |
| | | 'Failed': '通话失败', |
| | | 'Cancelled': '通话已取消', |
| | | 'Drafted': '草稿' |
| | | }; |
| | | // 活跃状态列表 |
| | | const ACTIVE_STATUSES = ['Scheduling', 'InProgress', 'Calling', 'Ringing', 'Answered']; |
| | | |
| | | /** |
| | | * 智能外呼通话显示组件 |
| | | * 基于 localStorage 中的 jobId 轮询查询通话状态 |
| | | * 支持多任务并行显示、自动清理终态任务 |
| | | * 显示在页面右下角的气泡组件,支持多人通话 |
| | | */ |
| | | const OutboundCallWidget = () => { |
| | | const [isVisible, setIsVisible] = useState(true); |
| | | const [isMinimized, setIsMinimized] = useState(false); |
| | | const { caseData } = useCaseData(); |
| | | const [isVisible, setIsVisible] = useState(false); // 默认隐藏 |
| | | const [isMinimized, setIsMinimized] = useState(true); |
| | | const [calls, setCalls] = useState([]); |
| | | const isMountedRef = useRef(true); |
| | | |
| | | /** |
| | | * 格式化通话时长 |
| | | * @param {number} startTime - 开始时间戳(毫秒) |
| | | * @returns {string} 格式化的时长(MM:SS) |
| | | */ |
| | | const formatDuration = (startTime) => { |
| | | if (!startTime) return '00:00'; |
| | | const seconds = Math.floor((Date.now() - startTime) / 1000); |
| | | // 轮询间隔(毫秒) |
| | | const POLL_INTERVAL = 10000; // 10秒 |
| | | |
| | | // 最大重试次数 |
| | | const MAX_RETRY_COUNT = 10; |
| | | |
| | | // 获取 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 []; |
| | |
| | | const cleanupJobs = (jobs) => { |
| | | const now = Date.now(); |
| | | return jobs.filter(job => { |
| | | // 检查是否为活跃状态 |
| | | if (ACTIVE_STATUSES.includes(job.callStatus)) { |
| | | // 检查是否超时(2小时) |
| | | if (now - job.pollStartTime > MAX_POLL_DURATION) { |
| | | const elapsed = now - (job.pollStartTime || job.startTime || now); |
| | | if (elapsed > 2 * 60 * 60 * 1000) { |
| | | console.warn('外呼轮询超时(2小时),自动停止,jobId:', job.jobId); |
| | | return false; |
| | | } |
| | | // 保留活跃状态 |
| | | return ACTIVE_STATUSES.includes(job.callStatus); |
| | | return true; |
| | | } |
| | | 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); |
| | | |
| | | // 遍历所有活跃任务,逐个查询状态(caseRef 和 jobId 都是必传参数) |
| | | // 并行查询所有任务的状态 |
| | | const updatedJobs = await Promise.all( |
| | | activeJobs.map(async (job) => { |
| | | successJobs.map(async (job) => { |
| | | try { |
| | | // 同时传入 caseRef 和 jobId |
| | | const response = await OutboundBotAPIService.getCallStatus({ |
| | | caseRef: job.caseId, |
| | | phoneNumber: job.phoneNumber, |
| | | jobId: job.jobId |
| | | }); |
| | | |
| | | if (response?.data) { |
| | | const newStatus = response.data.callStatus; |
| | | |
| | | // 更新任务状态 |
| | | const updatedJob = { |
| | | ...job, |
| | | callStatus: newStatus, |
| | | retryCount: 0 // 成功后重置重试计数 |
| | | }; |
| | | // 如果状态发生变化,更新任务 |
| | | if (newStatus !== job.callStatus) { |
| | | console.log(`任务 ${job.jobId} 状态更新: ${job.callStatus} -> ${newStatus}`); |
| | | |
| | | // 检测终态 |
| | | if (TERMINAL_STATUSES.includes(newStatus)) { |
| | | console.log('检测到终态,jobId:', job.jobId, ', status:', newStatus); |
| | | // 如果是终态,可以从轮询中移除 |
| | | if (!ACTIVE_STATUSES.includes(newStatus)) { |
| | | console.log(`任务 ${job.jobId} 达到终态: ${newStatus}`); |
| | | return null; // 标记为删除 |
| | | } |
| | | |
| | | return updatedJob; |
| | | return { |
| | | ...job, |
| | | callStatus: newStatus, |
| | | pollStartTime: Date.now(), // 重置超时计时 |
| | | retryCount: 0 // 重置重试计数 |
| | | }; |
| | | } |
| | | |
| | | // 状态未变化,保留原任务 |
| | | 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; |
| | |
| | | // 保存到 localStorage |
| | | saveJobsToStorage(cleanedJobs); |
| | | |
| | | // 合并成功任务和失败任务 |
| | | const allJobs = [...cleanedJobs, ...failedJobs]; |
| | | |
| | | // 更新组件状态 |
| | | if (isMountedRef.current) { |
| | | setCalls(cleanedJobs); |
| | | setCalls(allJobs); |
| | | // 如果有任务,显示气泡 |
| | | if (allJobs.length > 0 && !isVisible) { |
| | | setIsVisible(true); |
| | | } |
| | | }, []); |
| | | } |
| | | }, [isVisible]); |
| | | |
| | | // 定时轮询通话状态 |
| | | 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> |
| | | |
| | | {/* 通话时长 */} |
| | | {/* 通话时长(仅对成功任务显示)*/} |
| | | {!call.errorCode && call.pollStartTime && ( |
| | | <div |
| | | style={{ |
| | | display: 'flex', |
| | |
| | | }} |
| | | > |
| | | <i className="far fa-clock" /> |
| | | <span>已持续: {formatDuration(call.startTime)}</span> |
| | | <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%)', |
| New file |
| | |
| | | import React, { useState, useEffect, useCallback, useRef } from 'react'; |
| | | import { message } from 'antd'; |
| | | import OutboundBotAPIService from '../../services/OutboundBotAPIService'; |
| | | |
| | | // 常量配置 |
| | | 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 STATUS_MAP = { |
| | | 'Scheduling': '拨号中', |
| | | 'Executing': '通话中', |
| | | 'Succeeded': '通话成功', |
| | | 'Paused': '暂停', |
| | | 'Failed': '通话失败', |
| | | 'Cancelled': '通话已取消', |
| | | 'Drafted': '草稿' |
| | | }; |
| | | |
| | | /** |
| | | * 智能外呼通话显示组件 |
| | | * 基于 localStorage 中的 jobId 轮询查询通话状态 |
| | | * 支持多任务并行显示、自动清理终态任务 |
| | | */ |
| | | const OutboundCallWidget = () => { |
| | | const [isVisible, setIsVisible] = useState(false); // 默认不显示 |
| | | const [isMinimized, setIsMinimized] = useState(false); |
| | | const [calls, setCalls] = useState([]); |
| | | const isMountedRef = useRef(true); |
| | | |
| | | /** |
| | | * 格式化通话时长 |
| | | * @param {number} startTime - 开始时间戳(毫秒) |
| | | * @returns {string} 格式化的时长(MM:SS) |
| | | */ |
| | | const formatDuration = (startTime) => { |
| | | if (!startTime) return '00:00'; |
| | | const seconds = Math.floor((Date.now() - startTime) / 1000); |
| | | const mins = Math.floor(seconds / 60); |
| | | const secs = seconds % 60; |
| | | return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; |
| | | }; |
| | | |
| | | /** |
| | | * 从 localStorage 读取外呼任务(包括成功和失败的任务) |
| | | * @returns {Array} 任务数组 |
| | | */ |
| | | const loadJobsFromStorage = () => { |
| | | try { |
| | | // 读取成功的任务 |
| | | 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 []; |
| | | } |
| | | }; |
| | | |
| | | /** |
| | | * 保存任务到 localStorage |
| | | * @param {Array} jobs - 任务数组 |
| | | */ |
| | | const saveJobsToStorage = (jobs) => { |
| | | try { |
| | | if (jobs.length === 0) { |
| | | localStorage.removeItem(OUTBOUND_JOBS_KEY); |
| | | } else { |
| | | localStorage.setItem(OUTBOUND_JOBS_KEY, JSON.stringify(jobs)); |
| | | } |
| | | } catch (err) { |
| | | console.error('保存外呼任务失败:', err); |
| | | } |
| | | }; |
| | | |
| | | /** |
| | | * 移除终态或超时的任务 |
| | | * @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; |
| | | } |
| | | // 保留活跃状态 |
| | | return ACTIVE_STATUSES.includes(job.callStatus); |
| | | }); |
| | | }; |
| | | |
| | | /** |
| | | * 查询通话状态(轮询核心逻辑) |
| | | */ |
| | | const fetchCallStatus = useCallback(async () => { |
| | | let jobs = loadJobsFromStorage(); |
| | | |
| | | // 分离成功任务和失败任务 |
| | | const successJobs = jobs.filter(job => !job.errorCode && ACTIVE_STATUSES.includes(job.callStatus)); |
| | | const failedJobs = jobs.filter(job => job.errorCode > 0); |
| | | |
| | | if (successJobs.length === 0 && failedJobs.length === 0) { |
| | | setCalls([]); |
| | | return; |
| | | } |
| | | |
| | | console.log('轮询查询通话状态,成功任务:', successJobs.length, ', 失败任务:', failedJobs.length); |
| | | |
| | | // 遍历所有活跃任务,逐个查询状态(caseRef 和 jobId 都是必传参数) |
| | | const updatedJobs = await Promise.all( |
| | | successJobs.map(async (job) => { |
| | | try { |
| | | // 同时传入 caseRef 和 jobId |
| | | const response = await OutboundBotAPIService.getCallStatus({ |
| | | caseRef: job.caseId, |
| | | 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; // 标记为删除 |
| | | } |
| | | |
| | | return updatedJob; |
| | | } |
| | | |
| | | return job; |
| | | } catch (err) { |
| | | console.warn('查询失败,重试次数:', job.retryCount + 1, '/', MAX_RETRY_COUNT, ', jobId:', job.jobId, ', 错误:', err.message); |
| | | |
| | | // 累加重试计数 |
| | | const retryCount = job.retryCount + 1; |
| | | |
| | | if (retryCount >= MAX_RETRY_COUNT) { |
| | | console.error('重试次数超限,jobId:', job.jobId); |
| | | message.error('外呼状态查询失败次数过多,已停止监控'); |
| | | return null; // 标记为删除 |
| | | } |
| | | |
| | | return { ...job, retryCount }; |
| | | } |
| | | }) |
| | | ); |
| | | |
| | | // 过滤掉标记为删除的任务(null) |
| | | const filteredJobs = updatedJobs.filter(job => job !== null); |
| | | |
| | | // 清理超时任务 |
| | | const cleanedJobs = cleanupJobs(filteredJobs); |
| | | |
| | | // 保存到 localStorage |
| | | saveJobsToStorage(cleanedJobs); |
| | | |
| | | // 合并成功任务和失败任务 |
| | | const allJobs = [...cleanedJobs, ...failedJobs]; |
| | | |
| | | // 更新组件状态 |
| | | if (isMountedRef.current) { |
| | | setCalls(allJobs); |
| | | // 如果有任务,显示气泡 |
| | | if (allJobs.length > 0 && !isVisible) { |
| | | setIsVisible(true); |
| | | } |
| | | } |
| | | }, [isVisible]); |
| | | |
| | | // 定时轮询通话状态 |
| | | useEffect(() => { |
| | | // 初始加载 |
| | | fetchCallStatus(); |
| | | |
| | | // 设置轮询定时器(10秒间隔) |
| | | const interval = setInterval(fetchCallStatus, POLL_INTERVAL); |
| | | |
| | | // 清理函数 |
| | | return () => { |
| | | clearInterval(interval); |
| | | isMountedRef.current = false; |
| | | }; |
| | | }, [fetchCallStatus]); |
| | | |
| | | // 组件挂载时标记 |
| | | useEffect(() => { |
| | | isMountedRef.current = true; |
| | | return () => { |
| | | isMountedRef.current = false; |
| | | }; |
| | | }, []); |
| | | |
| | | // 关闭气泡 |
| | | const handleClose = (e) => { |
| | | e.stopPropagation(); |
| | | setIsVisible(false); |
| | | setIsMinimized(true); |
| | | }; |
| | | |
| | | // 展开气泡 |
| | | const handleExpand = () => { |
| | | setIsMinimized(false); |
| | | 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} |
| | | style={{ |
| | | position: 'fixed', |
| | | right: 20, |
| | | bottom: 80, |
| | | width: 56, |
| | | height: 56, |
| | | borderRadius: '50%', |
| | | background: 'linear-gradient(135deg, #1A6FB8 0%, #0d4a8a 100%)', |
| | | display: 'flex', |
| | | alignItems: 'center', |
| | | justifyContent: 'center', |
| | | cursor: 'pointer', |
| | | boxShadow: '0 4px 12px rgba(26, 111, 184, 0.4)', |
| | | zIndex: 1000, |
| | | transition: 'all 0.3s ease', |
| | | }} |
| | | onMouseEnter={(e) => { |
| | | e.currentTarget.style.transform = 'scale(1.1)'; |
| | | }} |
| | | onMouseLeave={(e) => { |
| | | e.currentTarget.style.transform = 'scale(1)'; |
| | | }} |
| | | > |
| | | <i |
| | | className="fas fa-headset" |
| | | style={{ |
| | | fontSize: 24, |
| | | color: 'white', |
| | | }} |
| | | /> |
| | | {/* 红点提示有通话 */} |
| | | {activeCalls.length > 0 && ( |
| | | <div |
| | | style={{ |
| | | position: 'absolute', |
| | | top: -2, |
| | | right: -2, |
| | | width: 16, |
| | | height: 16, |
| | | borderRadius: '50%', |
| | | background: '#ff4d4f', |
| | | display: 'flex', |
| | | alignItems: 'center', |
| | | justifyContent: 'center', |
| | | fontSize: 10, |
| | | color: 'white', |
| | | fontWeight: 'bold', |
| | | }} |
| | | > |
| | | {activeCalls.length} |
| | | </div> |
| | | )} |
| | | </div> |
| | | ); |
| | | } |
| | | |
| | | // 过滤掉已过期的任务(用于气泡显示) |
| | | 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; |
| | | }); |
| | | |
| | | // 展开状态 - 显示通话气泡 |
| | | return ( |
| | | <div |
| | | style={{ |
| | | position: 'fixed', |
| | | right: 20, |
| | | bottom: 80, |
| | | zIndex: 1000, |
| | | display: 'flex', |
| | | flexDirection: 'column', |
| | | gap: 10, |
| | | maxWidth: 320, |
| | | }} |
| | | > |
| | | {activeCalls.map((call, index) => ( |
| | | <div |
| | | key={call.jobId || call.phoneNumber || index} |
| | | style={{ |
| | | background: 'linear-gradient(135deg, #1A6FB8 0%, #0d4a8a 100%)', // 统一使用蓝色渐变 |
| | | borderRadius: 12, |
| | | padding: '16px 20px', |
| | | color: 'white', |
| | | boxShadow: '0 4px 16px rgba(26, 111, 184, 0.3)', // 统一使用蓝色阴影 |
| | | position: 'relative', |
| | | minWidth: 280, |
| | | }} |
| | | > |
| | | {/* 关闭按钮 */} |
| | | <button |
| | | onClick={handleClose} |
| | | style={{ |
| | | position: 'absolute', |
| | | top: 8, |
| | | right: 8, |
| | | width: 24, |
| | | height: 24, |
| | | borderRadius: '50%', |
| | | border: 'none', |
| | | background: 'rgba(255,255,255,0.2)', |
| | | color: 'white', |
| | | cursor: 'pointer', |
| | | display: 'flex', |
| | | alignItems: 'center', |
| | | justifyContent: 'center', |
| | | fontSize: 14, |
| | | transition: 'all 0.2s ease', |
| | | }} |
| | | onMouseEnter={(e) => { |
| | | e.currentTarget.style.background = 'rgba(255,255,255,0.3)'; |
| | | }} |
| | | onMouseLeave={(e) => { |
| | | e.currentTarget.style.background = 'rgba(255,255,255,0.2)'; |
| | | }} |
| | | > |
| | | <i className="fas fa-times" /> |
| | | </button> |
| | | |
| | | {/* 头部 - 智能体工作中 */} |
| | | <div |
| | | style={{ |
| | | display: 'flex', |
| | | alignItems: 'center', |
| | | gap: 10, |
| | | marginBottom: 12, |
| | | }} |
| | | > |
| | | <div |
| | | style={{ |
| | | width: 36, |
| | | height: 36, |
| | | borderRadius: '50%', |
| | | background: 'rgba(255,255,255,0.2)', |
| | | display: 'flex', |
| | | alignItems: 'center', |
| | | justifyContent: 'center', |
| | | }} |
| | | > |
| | | <i className="fas fa-robot" style={{ fontSize: 18 }} /> |
| | | </div> |
| | | <div style={{ flex: 1 }}> |
| | | <div |
| | | style={{ |
| | | fontSize: 16, |
| | | fontWeight: 600, |
| | | display: 'flex', |
| | | alignItems: 'center', |
| | | gap: 8, |
| | | }} |
| | | > |
| | | 智能体工作中 |
| | | <span |
| | | style={{ |
| | | width: 8, |
| | | height: 8, |
| | | borderRadius: '50%', |
| | | background: call.errorCode > 0 ? '#ff4d4f' : '#52c41a', // 失败显示红色,成功显示绿色 |
| | | animation: 'pulse 2s infinite', |
| | | }} |
| | | /> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | {/* 通话信息 */} |
| | | <div style={{ marginBottom: 10 }}> |
| | | <div style={{ fontSize: 14, opacity: 0.9, lineHeight: 1.5 }}> |
| | | {call.errorCode > 0 ? ( |
| | | // 失败任务显示 |
| | | <span> |
| | | {call.perClassName || '联系人'} |
| | | {call.trueName && `(${call.trueName})`}: |
| | | {call.message} |
| | | </span> |
| | | ) : ( |
| | | // 成功任务显示 |
| | | <span> |
| | | 正在与{call.perClassName || '申请方'}({call.trueName || call.personId})电话沟通中... |
| | | </span> |
| | | )} |
| | | </div> |
| | | </div> |
| | | |
| | | {/* 状态显示(仅对成功任务) */} |
| | | {!call.errorCode && ( |
| | | <div style={{ fontSize: 12, opacity: 0.75, marginTop: 4 }}> |
| | | 状态:{STATUS_MAP[call.callStatus] || call.callStatus} |
| | | </div> |
| | | )} |
| | | |
| | | {/* 通话时长(仅对成功任务) */} |
| | | {!call.errorCode && ( |
| | | <div |
| | | style={{ |
| | | display: 'flex', |
| | | alignItems: 'center', |
| | | gap: 6, |
| | | fontSize: 13, |
| | | opacity: 0.85, |
| | | }} |
| | | > |
| | | <i className="far fa-clock" /> |
| | | <span>已持续: {formatDuration(call.startTime)}</span> |
| | | </div> |
| | | )} |
| | | </div> |
| | | ))} |
| | | |
| | | {/* 无通话时的占位提示 */} |
| | | {calls.length === 0 && isVisible && ( |
| | | <div |
| | | style={{ |
| | | background: 'linear-gradient(135deg, #1A6FB8 0%, #0d4a8a 100%)', |
| | | borderRadius: 12, |
| | | padding: '16px 20px', |
| | | color: 'white', |
| | | boxShadow: '0 4px 16px rgba(26, 111, 184, 0.3)', |
| | | position: 'relative', |
| | | minWidth: 280, |
| | | }} |
| | | > |
| | | {/* 关闭按钮 */} |
| | | <button |
| | | onClick={handleClose} |
| | | style={{ |
| | | position: 'absolute', |
| | | top: 8, |
| | | right: 8, |
| | | width: 24, |
| | | height: 24, |
| | | borderRadius: '50%', |
| | | border: 'none', |
| | | background: 'rgba(255,255,255,0.2)', |
| | | color: 'white', |
| | | cursor: 'pointer', |
| | | display: 'flex', |
| | | alignItems: 'center', |
| | | justifyContent: 'center', |
| | | fontSize: 14, |
| | | }} |
| | | > |
| | | <i className="fas fa-times" /> |
| | | </button> |
| | | |
| | | <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}> |
| | | <div |
| | | style={{ |
| | | width: 36, |
| | | height: 36, |
| | | borderRadius: '50%', |
| | | background: 'rgba(255,255,255,0.2)', |
| | | display: 'flex', |
| | | alignItems: 'center', |
| | | justifyContent: 'center', |
| | | }} |
| | | > |
| | | <i className="fas fa-robot" style={{ fontSize: 18 }} /> |
| | | </div> |
| | | <div> |
| | | <div style={{ fontSize: 16, fontWeight: 600 }}>智能外呼系统</div> |
| | | <div style={{ fontSize: 13, opacity: 0.85 }}>暂无进行中的通话</div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | )} |
| | | |
| | | {/* CSS 动画 */} |
| | | <style>{` |
| | | @keyframes pulse { |
| | | 0%, 100% { opacity: 1; } |
| | | 50% { opacity: 0.5; } |
| | | } |
| | | `}</style> |
| | | </div> |
| | | ); |
| | | }; |
| | | |
| | | export default OutboundCallWidget; |
| | |
| | | personId: item.personId, |
| | | mediationId: item.mediationId, |
| | | caseId: String(caseId), // 添加 caseId 字段用于轮询 |
| | | startTime: Date.now(), |
| | | perClassName: item.perClassName || '', // 添加人员类型名称 |
| | | trueName: item.trueName || '', // 添加真实姓名 |
| | | startTime: item.createdTime || item.start_time, |
| | | pollStartTime: Date.now(), |
| | | retryCount: 0 |
| | | }); |
| | | } else { |
| | | } else if (item.errorCode > 0) { |
| | | failedJobs.push({ |
| | | personId: item.personId, |
| | | message: item.message || '未知错误' |
| | | message: item.message || '未知错误', |
| | | perClassName: item.perClassName || '', // 添加人员类型名称 |
| | | trueName: item.trueName || '', // 添加真实姓名 |
| | | errorCode: item.errorCode, // 添加错误码 |
| | | startTime: new Date(item.startTime || item.start_time).getTime() // 转换为时间戳 |
| | | }); |
| | | } |
| | | }); |
| | |
| | | if (successJobs.length > 0) { |
| | | localStorage.setItem(OUTBOUND_JOBS_KEY, JSON.stringify(successJobs)); |
| | | console.log('存储外呼任务成功,数量:', successJobs.length); |
| | | } |
| | | |
| | | // 存储失败的任务到 localStorage(用于气泡显示) |
| | | if (failedJobs.length > 0) { |
| | | console.log('准备存储失败任务:', failedJobs); |
| | | // 读取现有的失败任务 |
| | | const storedFailedJobs = JSON.parse(localStorage.getItem(`${OUTBOUND_JOBS_KEY}_failed`) || '[]'); |
| | | |
| | | // 去重:按 personId 去重,保留最新的错误信息 |
| | | const uniqueFailedJobs = [...storedFailedJobs]; |
| | | failedJobs.forEach(newJob => { |
| | | const existingIndex = uniqueFailedJobs.findIndex(job => job.personId === newJob.personId); |
| | | if (existingIndex >= 0) { |
| | | // 更新已存在的失败任务 |
| | | uniqueFailedJobs[existingIndex] = newJob; |
| | | } else { |
| | | // 添加新的失败任务 |
| | | uniqueFailedJobs.push(newJob); |
| | | } |
| | | }); |
| | | |
| | | // 清理超过24小时的失败任务 |
| | | const now = Date.now(); |
| | | const cleanedFailedJobs = uniqueFailedJobs.filter(job => { |
| | | return (now - job.startTime) < 24 * 60 * 60 * 1000; // 24小时 |
| | | }); |
| | | |
| | | localStorage.setItem(`${OUTBOUND_JOBS_KEY}_failed`, JSON.stringify(cleanedFailedJobs)); |
| | | console.log('存储外呼失败任务,数量:', cleanedFailedJobs.length); |
| | | } |
| | | |
| | | // 提示失败的任务 |
| | |
| | | message.error('加载案件数据失败,请稍后重试'); |
| | | |
| | | // 使用Mock数据(缓存数据不包含nodes,所以统一使用Mock) |
| | | console.log('===== 使用Mock数据 ====='); |
| | | const mockData = mockTimelineData.data.timeline; |
| | | const mockNodes = mockTimelineData.data.nodes || []; |
| | | console.log('mockData:', mockData); |
| | | console.log('mockNodes:', mockNodes); |
| | | setCaseData(mockData); |
| | | setProcessNodes(mockNodes); |
| | | saveToStorage(mockData); |
| | | setHasLoaded(true); |
| | | |
| | | // Mock数据也加载任务时间 |
| | | await loadTaskTime(mockData); |
| | | } finally { |
| | | setLoading(false); |
| | | } |
| | |
| | | import { request } from './request'; |
| | | |
| | | class OutboundBotAPIService { |
| | | /** |
| | | * 电话拨打获取记录 |
| | | * POST /api/v1/outbound-bot/call |
| | | * @param {Object} data - 请求数据 |
| | | * @param {string} data.caseRef - 案件引用ID |
| | | * @param {string} data.phoneNumber - 电话号码 |
| | | * @param {string} data.scriptId - 脚本ID |
| | | * @returns {Promise} 外呼结果 |
| | | */ |
| | | static makeCall(data = {}) { |
| | | return request.post('/api/v1/outbound-bot/call', data); |
| | | } |
| | | |
| | | |
| | | /** |
| | | * 智能外呼拨打电话 |