| | |
| | | const [calls, setCalls] = useState([]); |
| | | const [mediationRecords, setMediationRecords] = useState([]); // AI调解记录 |
| | | const [aiProcessingTasks, setAiProcessingTasks] = useState([]); // AI处理中任务 |
| | | const [countdownInfo, setCountdownInfo] = useState(null); // 下一节点外呼倒计时 |
| | | const isMountedRef = useRef(true); |
| | | |
| | | // 使用 ref 跟踪 isVisible,避免 useCallback 依赖 isVisible 导致轮询 useEffect 级联重建 |
| | | const isVisibleRef = useRef(isVisible); |
| | | useEffect(() => { isVisibleRef.current = isVisible; }, [isVisible]); |
| | | |
| | | // 轮询间隔(毫秒) |
| | | const POLL_INTERVAL = 2000; // 2秒 |
| | | const POLL_INTERVAL = 2000; |
| | | |
| | | // 节点数据轻量刷新间隔(毫秒)—— 与主轮询对齐,避免节点进度延迟 |
| | | const NODE_REFRESH_INTERVAL = 2000; // 2秒 |
| | | const NODE_REFRESH_INTERVAL = 2000; |
| | | |
| | | // 最大重试次数 |
| | | const MAX_RETRY_COUNT = 10; |
| | |
| | | |
| | | // 获取 mediationId |
| | | const mediationId = caseData?.mediation?.id; |
| | | |
| | | // 本地倒计时定时器:每秒递减 remaining_seconds,避免仅依赖轮询导致的跳动 |
| | | useEffect(() => { |
| | | if (!countdownInfo || countdownInfo.remaining_seconds <= 0) return; |
| | | const timer = setInterval(() => { |
| | | setCountdownInfo(prev => { |
| | | if (!prev) return null; |
| | | const next = prev.remaining_seconds - 1; |
| | | if (next <= 0) return null; // 倒计时结束,清除 |
| | | return { ...prev, remaining_seconds: next }; |
| | | }); |
| | | }, 1000); |
| | | return () => clearInterval(timer); |
| | | }, [countdownInfo?.remaining_seconds > 0]); // 仅在倒计时存在时启动 |
| | | |
| | | // 组件挂载/卸载生命周期(独立管理,不受轮询依赖变化影响) |
| | | useEffect(() => { |
| | | isMountedRef.current = true; |
| | | return () => { isMountedRef.current = false; }; |
| | | }, []); |
| | | |
| | | // 加载AI调解记录(无Loading效果) |
| | | const loadMediationRecords = useCallback(async () => { |
| | |
| | | }; |
| | | |
| | | /** |
| | | * 查询下一节点外呼倒计时 |
| | | */ |
| | | const fetchCountdownInfo = useCallback(async () => { |
| | | if (!mediationId) return; |
| | | try { |
| | | const response = await OutboundBotAPIService.getNextNodeCountdown({ mediation_id: mediationId }); |
| | | const data = response?.data; |
| | | if (data && data.countdown && data.remaining_seconds > 0) { |
| | | if (isMountedRef.current) { |
| | | setCountdownInfo({ |
| | | remaining_seconds: data.remaining_seconds, |
| | | next_node_name: data.next_node_name || '', |
| | | delay_seconds: data.delay_seconds || 0, |
| | | }); |
| | | // 倒计时存在时显示悬浮窗 |
| | | if (!isVisibleRef.current) { |
| | | setIsVisible(true); |
| | | } |
| | | } |
| | | } else { |
| | | if (isMountedRef.current) { |
| | | setCountdownInfo(null); |
| | | } |
| | | } |
| | | } catch (e) { |
| | | console.warn('[Widget] 查询倒计时失败:', e); |
| | | } |
| | | }, [mediationId]); |
| | | |
| | | /** |
| | | * 查询通话状态 |
| | | */ |
| | | const fetchCallStatus = useCallback(async () => { |
| | |
| | | // 没有活跃任务,更新状态并返回 |
| | | if (isMountedRef.current) { |
| | | setCalls([...failedJobs]); |
| | | if (failedJobs.length > 0 && !isVisible) { |
| | | if (failedJobs.length > 0 && !isVisibleRef.current) { |
| | | setIsVisible(true); |
| | | } |
| | | } |
| | |
| | | if (isMountedRef.current) { |
| | | setCalls(allJobs); |
| | | // 如果有任务,显示气泡 |
| | | if (allJobs.length > 0 && !isVisible) { |
| | | if (allJobs.length > 0 && !isVisibleRef.current) { |
| | | setIsVisible(true); |
| | | } |
| | | } |
| | | }, [isVisible, triggerPageUpdate, mediationId]); |
| | | }, [triggerPageUpdate, mediationId]); |
| | | |
| | | // 记录上一次 AI 处理任务数,用于检测 "处理中→完成" 变化 |
| | | const prevAiTaskCountRef = useRef(0); |
| | |
| | | } |
| | | |
| | | // 如果有 AI 处理中任务,确保悬浮窗可见 |
| | | if (tasks.length > 0 && !isVisible) { |
| | | if (tasks.length > 0 && !isVisibleRef.current) { |
| | | setIsVisible(true); |
| | | } |
| | | } |
| | |
| | | // 不影响主流程,静默失败 |
| | | console.warn('[Widget] 查询AI处理状态失败:', e); |
| | | } |
| | | }, [mediationId, isVisible, refreshNodeData]); |
| | | }, [mediationId, refreshNodeData]); |
| | | |
| | | // 定时轮询通话状态 |
| | | // 使用 ref 持有最新回调,供轮询定时器调用,避免 setInterval 闭包捕获过期函数 |
| | | const fetchCallStatusRef = useRef(fetchCallStatus); |
| | | const loadMediationRecordsRef = useRef(loadMediationRecords); |
| | | const fetchAiProcessingStatusRef = useRef(fetchAiProcessingStatus); |
| | | const fetchCountdownInfoRef = useRef(fetchCountdownInfo); |
| | | useEffect(() => { fetchCallStatusRef.current = fetchCallStatus; }, [fetchCallStatus]); |
| | | useEffect(() => { loadMediationRecordsRef.current = loadMediationRecords; }, [loadMediationRecords]); |
| | | useEffect(() => { fetchAiProcessingStatusRef.current = fetchAiProcessingStatus; }, [fetchAiProcessingStatus]); |
| | | useEffect(() => { fetchCountdownInfoRef.current = fetchCountdownInfo; }, [fetchCountdownInfo]); |
| | | |
| | | // 定时轮询通话状态(仅依赖 mediationId,避免回调引用变化导致定时器反复重建) |
| | | useEffect(() => { |
| | | // 组件挂载时设置为 true |
| | | isMountedRef.current = true; |
| | | // mediationId 尚未就绪时不启动轮询,等待 caseData 加载完毕 |
| | | if (!mediationId) { |
| | | console.log('[Widget] mediationId 未就绪,延迟启动轮询'); |
| | | return; |
| | | } |
| | | |
| | | console.log('[Widget] 启动轮询, mediationId:', mediationId); |
| | | |
| | | // 初始加载 |
| | | fetchCallStatus(); |
| | | loadMediationRecords(); // 初始加载调解记录 |
| | | |
| | | // AI 处理状态初始加载 |
| | | fetchAiProcessingStatus(); |
| | | fetchCallStatusRef.current(); |
| | | loadMediationRecordsRef.current(); |
| | | fetchAiProcessingStatusRef.current(); |
| | | fetchCountdownInfoRef.current(); |
| | | |
| | | // 设置轮询定时器(10秒间隔) |
| | | // 设置轮询定时器(通过 ref 调用最新回调,避免闭包过期) |
| | | const interval = setInterval(() => { |
| | | fetchCallStatus(); |
| | | loadMediationRecords(); // 每10秒加载一次AI调解记录 |
| | | fetchAiProcessingStatus(); // 每10秒查询AI处理状态 |
| | | fetchCallStatusRef.current(); |
| | | loadMediationRecordsRef.current(); |
| | | fetchAiProcessingStatusRef.current(); |
| | | fetchCountdownInfoRef.current(); |
| | | }, POLL_INTERVAL); |
| | | |
| | | // 监听外呼任务更新事件(立即刷新) |
| | | const handleOutboundJobsUpdated = () => { |
| | | console.log('收到外呼任务更新事件,立即刷新'); |
| | | fetchCallStatus(); |
| | | fetchCallStatusRef.current(); |
| | | }; |
| | | window.addEventListener('outbound-jobs-updated', handleOutboundJobsUpdated); |
| | | |
| | |
| | | |
| | | // 清理函数 |
| | | return () => { |
| | | console.log('[Widget] 清理轮询定时器'); |
| | | clearInterval(interval); |
| | | window.removeEventListener('outbound-jobs-updated', handleOutboundJobsUpdated); |
| | | window.removeEventListener('mediation-terminated', handleMediationTerminated); |
| | | isMountedRef.current = false; |
| | | }; |
| | | }, [fetchCallStatus, loadMediationRecords, fetchAiProcessingStatus]); |
| | | }, [mediationId]); // 仅在 mediationId 变化时重建轮询 |
| | | |
| | | // 独立定时器:周期性轻量刷新 timeline + processNodes(不依赖闭包变量,避免过期状态问题) |
| | | useEffect(() => { |
| | |
| | | return true; |
| | | }); |
| | | |
| | | // 如果没有活跃任务且没有AI处理中任务且不可见,不渲染任何内容 |
| | | if (activeCalls.length === 0 && aiProcessingTasks.length === 0 && !isVisible) { |
| | | // 如果没有活跃任务且没有AI处理中任务且没有倒计时且不可见,不渲染任何内容 |
| | | if (activeCalls.length === 0 && aiProcessingTasks.length === 0 && !countdownInfo && !isVisible) { |
| | | return null; |
| | | } |
| | | |
| | |
| | | color: 'white', |
| | | }} |
| | | /> |
| | | {/* 红点提示有通话或AI处理中 */} |
| | | {(activeCalls.length + aiProcessingTasks.length) > 0 && ( |
| | | {/* 红点提示有通话、AI处理中或倒计时 */} |
| | | {(activeCalls.length + aiProcessingTasks.length + (countdownInfo ? 1 : 0)) > 0 && ( |
| | | <div |
| | | style={{ |
| | | position: 'absolute', |
| | |
| | | width: 16, |
| | | height: 16, |
| | | borderRadius: '50%', |
| | | background: aiProcessingTasks.length > 0 ? '#722ED1' : '#ff4d4f', |
| | | background: countdownInfo ? '#FA8C16' : aiProcessingTasks.length > 0 ? '#722ED1' : '#ff4d4f', |
| | | display: 'flex', |
| | | alignItems: 'center', |
| | | justifyContent: 'center', |
| | |
| | | fontWeight: 'bold', |
| | | }} |
| | | > |
| | | {activeCalls.length + aiProcessingTasks.length} |
| | | {activeCalls.length + aiProcessingTasks.length + (countdownInfo ? 1 : 0)} |
| | | </div> |
| | | )} |
| | | </div> |
| | |
| | | </div> |
| | | ))} |
| | | |
| | | {/* 下一节点外呼倒计时卡片 */} |
| | | {countdownInfo && countdownInfo.remaining_seconds > 0 && ( |
| | | <div |
| | | style={{ |
| | | background: 'linear-gradient(135deg, #FA8C16 0%, #D46B08 100%)', |
| | | borderRadius: 12, |
| | | padding: '16px 20px', |
| | | color: 'white', |
| | | boxShadow: '0 4px 16px rgba(250, 140, 22, 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-hourglass-half" style={{ fontSize: 18, animation: 'pulse 2s infinite' }} /> |
| | | </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: '#FFC069', |
| | | animation: 'pulse 2s infinite', |
| | | }} |
| | | /> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | {/* 倒计时信息 */} |
| | | <div style={{ marginBottom: 10 }}> |
| | | <div style={{ fontSize: 14, opacity: 0.95, lineHeight: 1.6 }}> |
| | | <span>即将进入:{countdownInfo.next_node_name || '下一节点'}</span> |
| | | </div> |
| | | </div> |
| | | |
| | | {/* 倒计时显示 */} |
| | | <div |
| | | style={{ |
| | | display: 'flex', |
| | | alignItems: 'center', |
| | | gap: 6, |
| | | fontSize: 13, |
| | | opacity: 0.9, |
| | | }} |
| | | > |
| | | <i className="far fa-clock" /> |
| | | <span> |
| | | {Math.floor(countdownInfo.remaining_seconds / 60)}分 |
| | | {String(countdownInfo.remaining_seconds % 60).padStart(2, '0')}秒后发起外呼 |
| | | </span> |
| | | </div> |
| | | </div> |
| | | )} |
| | | |
| | | {/* AI 处理中加载态卡片 */} |
| | | {aiProcessingTasks.map((task, index) => ( |
| | | <div |
| | |
| | | ))} |
| | | |
| | | {/* 无通话时的占位提示 */} |
| | | {activeCalls.length === 0 && aiProcessingTasks.length === 0 && isVisible && ( |
| | | {activeCalls.length === 0 && aiProcessingTasks.length === 0 && !countdownInfo && isVisible && ( |
| | | <div |
| | | style={{ |
| | | background: 'linear-gradient(135deg, #1A6FB8 0%, #0d4a8a 100%)', |