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] 修复智能外呼气泡组件变量初始化错误和时间格式问题
---
web-app/src/components/common/OutboundCallWidget.jsx | 565 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 files changed, 565 insertions(+), 0 deletions(-)
diff --git a/web-app/src/components/common/OutboundCallWidget.jsx b/web-app/src/components/common/OutboundCallWidget.jsx
new file mode 100644
index 0000000..f5bdce5
--- /dev/null
+++ b/web-app/src/components/common/OutboundCallWidget.jsx
@@ -0,0 +1,565 @@
+import React, { useState, useEffect, useCallback, useRef } from 'react';
+import { useCaseData } from '../../contexts/CaseDataContext';
+import OutboundBotAPIService from '../../services/OutboundBotAPIService';
+import { message } from 'antd';
+
+const OUTBOUND_JOBS_KEY = 'outbound_call_jobs';
+
+// 活跃状态列表
+const ACTIVE_STATUSES = ['Scheduling', 'InProgress', 'Calling', 'Ringing', 'Answered'];
+
+/**
+ * 智能外呼通话显示组件
+ * 显示在页面右下角的气泡组件,支持多人通话
+ */
+const OutboundCallWidget = () => {
+ const { caseData } = useCaseData();
+ const [isVisible, setIsVisible] = useState(false); // 默认隐藏
+ const [isMinimized, setIsMinimized] = useState(true);
+ const [calls, setCalls] = useState([]);
+ const isMountedRef = useRef(true);
+
+ // 轮询间隔(毫秒)
+ 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 读取外呼任务(包括成功和失败的任务)
+ * @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 => {
+ // 检查是否为活跃状态
+ 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 false;
+ });
+ };
+
+ /**
+ * 查询通话状态
+ */
+ const fetchCallStatus = useCallback(async () => {
+ // 从 localStorage 读取任务
+ const storedJobs = loadJobsFromStorage();
+
+ // 分离成功任务和失败任务
+ const successJobs = storedJobs.filter(job => !job.errorCode && ACTIVE_STATUSES.includes(job.callStatus));
+ const failedJobs = storedJobs.filter(job => job.errorCode > 0);
+
+ if (successJobs.length === 0) {
+ // 没有活跃任务,更新状态并返回
+ if (isMountedRef.current) {
+ setCalls([...failedJobs]);
+ if (failedJobs.length > 0 && !isVisible) {
+ setIsVisible(true);
+ }
+ }
+ return;
+ }
+
+ // 并行查询所有任务的状态
+ const updatedJobs = await Promise.all(
+ successJobs.map(async (job) => {
+ try {
+ const response = await OutboundBotAPIService.getCallStatus({
+ caseRef: job.caseId,
+ phoneNumber: job.phoneNumber,
+ jobId: job.jobId
+ });
+
+ if (response?.data) {
+ const newStatus = response.data.callStatus;
+
+ // 如果状态发生变化,更新任务
+ if (newStatus !== job.callStatus) {
+ console.log(`任务 ${job.jobId} 状态更新: ${job.callStatus} -> ${newStatus}`);
+
+ // 如果是终态,可以从轮询中移除
+ if (!ACTIVE_STATUSES.includes(newStatus)) {
+ console.log(`任务 ${job.jobId} 达到终态: ${newStatus}`);
+ return null; // 标记为删除
+ }
+
+ return {
+ ...job,
+ callStatus: newStatus,
+ pollStartTime: Date.now(), // 重置超时计时
+ retryCount: 0 // 重置重试计数
+ };
+ }
+
+ // 状态未变化,保留原任务
+ return job;
+ }
+
+ // API 返回空数据,保留原任务
+ return job;
+ } catch (err) {
+ console.error('获取通话状态失败:', err);
+
+ // 累加重试计数
+ 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]);
+
+ // 关闭气泡
+ 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>
+ );
+ }
+
+ // 展开状态 - 显示通话气泡
+ 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 && 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>
+ ))}
+
+ {/* 无通话时的占位提示 */}
+ {activeCalls.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;
\ No newline at end of file
--
Gitblit v1.8.0