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/App.js | 2
web-app/src/services/OutboundBotAPIService.js | 13
web-app/src/contexts/CaseDataContext.jsx | 51 ++
web-app/src/components/common/OutboundCallWidget.jsx.bak | 600 +++++++++++++++++++++++++++++++++++
web-app/src/components/common/OutboundCallWidget.jsx | 299 +++++++++++------
5 files changed, 835 insertions(+), 130 deletions(-)
diff --git a/web-app/src/App.js b/web-app/src/App.js
index c687f46..dc6e992 100644
--- a/web-app/src/App.js
+++ b/web-app/src/App.js
@@ -114,7 +114,7 @@
</ToolModal>
)}
- {/* 智能外呼通话显示组件 - 全局显示 */}
+ {/* 智能外呼通话显示组件 - 默认隐藏,可主动触发显示 */}
<OutboundCallWidget />
</div>
</Spin>
diff --git a/web-app/src/components/common/OutboundCallWidget.jsx b/web-app/src/components/common/OutboundCallWidget.jsx
index cc26887..f5bdce5 100644
--- a/web-app/src/components/common/OutboundCallWidget.jsx
+++ b/web-app/src/components/common/OutboundCallWidget.jsx
@@ -1,61 +1,103 @@
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);
+
+ // 轮询间隔(毫秒)
+ 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 [];
@@ -86,64 +128,81 @@
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);
-
- // 遍历所有活跃任务,逐个查询状态(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}`);
+
+ // 如果是终态,可以从轮询中移除
+ 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;
@@ -168,11 +227,18 @@
// 保存到 localStorage
saveJobsToStorage(cleanedJobs);
+ // 合并成功任务和失败任务
+ const allJobs = [...cleanedJobs, ...failedJobs];
+
// 更新组件状态
if (isMountedRef.current) {
- setCalls(cleanedJobs);
+ setCalls(allJobs);
+ // 如果有任务,显示气泡
+ if (allJobs.length > 0 && !isVisible) {
+ setIsVisible(true);
+ }
}
- }, []);
+ }, [isVisible]);
// 定时轮询通话状态
useEffect(() => {
@@ -189,14 +255,6 @@
};
}, [fetchCallStatus]);
- // 组件挂载时标记
- useEffect(() => {
- isMountedRef.current = true;
- return () => {
- isMountedRef.current = false;
- };
- }, []);
-
// 关闭气泡
const handleClose = (e) => {
e.stopPropagation();
@@ -210,10 +268,32 @@
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}
@@ -248,7 +328,7 @@
}}
/>
{/* 红点提示有通话 */}
- {calls.length > 0 && (
+ {activeCalls.length > 0 && (
<div
style={{
position: 'absolute',
@@ -266,7 +346,7 @@
fontWeight: 'bold',
}}
>
- {calls.length}
+ {activeCalls.length}
</div>
)}
</div>
@@ -287,7 +367,7 @@
maxWidth: 320,
}}
>
- {calls.map((call, index) => (
+ {activeCalls.map((call, index) => (
<div
key={call.jobId || call.phoneNumber || index}
style={{
@@ -368,7 +448,7 @@
width: 8,
height: 8,
borderRadius: '50%',
- background: '#52c41a',
+ background: call.errorCode > 0 ? '#ff4d4f' : '#52c41a',
animation: 'pulse 2s infinite',
}}
/>
@@ -379,31 +459,42 @@
{/* 通话信息 */}
<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%)',
@@ -471,4 +562,4 @@
);
};
-export default OutboundCallWidget;
+export default OutboundCallWidget;
\ No newline at end of file
diff --git a/web-app/src/components/common/OutboundCallWidget.jsx.bak b/web-app/src/components/common/OutboundCallWidget.jsx.bak
new file mode 100644
index 0000000..7862611
--- /dev/null
+++ b/web-app/src/components/common/OutboundCallWidget.jsx.bak
@@ -0,0 +1,600 @@
+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;
diff --git a/web-app/src/contexts/CaseDataContext.jsx b/web-app/src/contexts/CaseDataContext.jsx
index c506d74..6c21aab 100644
--- a/web-app/src/contexts/CaseDataContext.jsx
+++ b/web-app/src/contexts/CaseDataContext.jsx
@@ -107,14 +107,20 @@
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() // 转换为时间戳
});
}
});
@@ -123,6 +129,35 @@
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);
}
// 提示失败的任务
@@ -241,18 +276,8 @@
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);
}
diff --git a/web-app/src/services/OutboundBotAPIService.js b/web-app/src/services/OutboundBotAPIService.js
index abd939b..e1880b8 100644
--- a/web-app/src/services/OutboundBotAPIService.js
+++ b/web-app/src/services/OutboundBotAPIService.js
@@ -7,18 +7,7 @@
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);
- }
+
/**
* 智能外呼拨打电话
--
Gitblit v1.8.0