| | |
| | | import React, { useState, useEffect, forwardRef, useImperativeHandle } from 'react'; |
| | | import React, { useState, useEffect, useCallback, useRef, forwardRef, useImperativeHandle } from 'react'; |
| | | import { useCaseData } from '../../contexts/CaseDataContext'; |
| | | import { formatDuration, formatSuccessRate, formatRoundCount } from '../../utils/stateTranslator'; |
| | | import { formatDuration, formatSuccessRate } from '../../utils/stateTranslator'; |
| | | import ProcessAPIService from '../../services/ProcessAPIService'; |
| | | import EvidenceAPIService from '../../services/EvidenceAPIService'; |
| | | import MediationAgreementAPIService from '../../services/MediationAgreementAPIService'; |
| | | import { getMergedParams } from '../../utils/urlParams'; |
| | | import { message, Spin, Tag, Modal, Button, Input, Image } from 'antd'; |
| | | import { PhoneOutlined } from '@ant-design/icons'; |
| | | import { CallRecordModal } from '../call-record'; |
| | | |
| | | // 新增组件导入 |
| | | import PartyInfoCard from './PartyInfoCard'; |
| | | import NegotiationProgress from './NegotiationProgress'; |
| | | import AISuggestionCard from './AISuggestionCard'; |
| | | |
| | | const { TextArea } = Input; |
| | | |
| | |
| | | }); |
| | | |
| | | /** |
| | | * 获取成功率同比数据 |
| | | */ |
| | | const getSuccessRateYoY = (mediation) => { |
| | | // 优先使用API返回的同比值 |
| | | if (mediation?.yoy_success_rate !== undefined && mediation?.yoy_success_rate !== null) { |
| | | return { |
| | | rate: mediation.yoy_success_rate, |
| | | hours: mediation.yoy_before_hours || 0 |
| | | }; |
| | | } |
| | | |
| | | // 计算同比值 |
| | | const currentRate = mediation?.success_rate || 0; |
| | | const lastRate = mediation?.last_success_rate || 0; |
| | | const diff = (currentRate - lastRate) * 100; |
| | | |
| | | return { |
| | | rate: diff, |
| | | hours: mediation?.yoy_before_hours || 0 |
| | | }; |
| | | }; |
| | | |
| | | /** |
| | | * 调解数据看板 |
| | | */ |
| | | const MediationDataBoard = () => { |
| | | const { caseData } = useCaseData(); |
| | | const timeline = caseData || {}; |
| | | const mediation = timeline.mediation || {}; |
| | | |
| | | // 从 timeline 获取数据 |
| | | const gapContent = timeline.result || '暂无分歧分析'; |
| | | const updateTime = formatDuration(timeline.before_duration); |
| | | const successRate = formatSuccessRate(timeline.mediation?.success_rate); |
| | | const roundCount = formatRoundCount(timeline.mediation?.mediation_count); |
| | | const successRate = formatSuccessRate(mediation.success_rate); |
| | | |
| | | // 获取成功率数值(用于进度条) |
| | | const successRateValue = (mediation.success_rate || 0) * 100; |
| | | |
| | | // 获取同比数据 |
| | | const yoyData = getSuccessRateYoY(mediation); |
| | | const yoyRate = yoyData.rate >= 0 ? `+${yoyData.rate.toFixed(0)}%` : `${yoyData.rate.toFixed(0)}%`; |
| | | const yoyHours = yoyData.hours; |
| | | |
| | | return ( |
| | | <div className="mediation-metrics"> |
| | | {/* 左侧:诉求差距分析 */} |
| | | <div className="metric-card"> |
| | | {/* 左侧:诉求差距分析 + AI建议 */} |
| | | <div className="metric-card left-column"> |
| | | <div className="metric-title"> |
| | | <i className="fas fa-exclamation-circle"></i> |
| | | <span>诉求差距分析</span> |
| | |
| | | {gapContent} |
| | | </div> |
| | | </div> |
| | | {/* AI调解建议 */} |
| | | <AISuggestionCard /> |
| | | </div> |
| | | </div> |
| | | |
| | | {/* 右侧:调解数据 */} |
| | | <div className="metric-card"> |
| | | <div className="metric-title"> |
| | | <i className="fas fa-exchange-alt"></i> |
| | | <span>调解数据</span> |
| | | </div> |
| | | <div className="metric-content"> |
| | | <div className="success-metric"> |
| | | <div className="success-value">{successRate}</div> |
| | | <div className="success-label">预计调解成功概率</div> |
| | | <div className="success-change"> |
| | | |
| | | {/* <i className="fas fa-arrow-up"></i><span>较{updateTime} +8%</span> */} |
| | | {/* 右侧:申请双方 + 成功率 + 协商沟通 */} |
| | | <div className="metric-card right-column"> |
| | | {/* 申请双方信息 */} |
| | | <PartyInfoCard /> |
| | | |
| | | {/* 预计调解成功率 */} |
| | | <div className="success-rate-section"> |
| | | <div className="success-rate-label">预计调解成功率</div> |
| | | <div className="success-rate-row"> |
| | | <span className="success-rate-value">{successRate}</span> |
| | | <div className="success-rate-yoy"> |
| | | <img src="/mom.png" alt="" className="yoy-icon-img" /> |
| | | <span className="yoy-rate">{yoyRate}</span> |
| | | <span className="yoy-time">较{yoyHours}小时前</span> |
| | | </div> |
| | | <div style={{ marginTop: 15, fontSize: '0.9rem', color: 'var(--gray-color)' }}> |
| | | 协商沟通:<span style={{ color: 'var(--dark-color)', fontWeight: 600 }}>{roundCount}</span> |
| | | </div> |
| | | <div className="success-rate-progress"> |
| | | <div className="progress-bar-bg"> |
| | | <div className="progress-bar-fill" style={{ width: `${successRateValue}%` }}></div> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | {/* 协商沟通进度 */} |
| | | <NegotiationProgress /> |
| | | </div> |
| | | </div> |
| | | ); |
| | |
| | | const [records, setRecords] = useState([]); |
| | | const [loading, setLoading] = useState(false); |
| | | const [error, setError] = useState(null); |
| | | |
| | | // 状态控制相关状态 |
| | | const [controlLoading, setControlLoading] = useState(false); |
| | | const [confirmModalVisible, setConfirmModalVisible] = useState(false); |
| | | const [controlAction, setControlAction] = useState(null); // 'terminate' or 'resume' |
| | | const [remark, setRemark] = useState(''); |
| | | // 通话记录弹窗状态 |
| | | const [callRecordVisible, setCallRecordVisible] = useState(false); |
| | | const [currentRecord, setCurrentRecord] = useState(null); |
| | | |
| | | // 获取案件数据 |
| | | const { caseData, refreshData } = useCaseData(); |
| | | const { caseData } = useCaseData(); |
| | | const timeline = caseData || {}; |
| | | const caseState = timeline.mediation?.state; |
| | | |
| | | |
| | | |
| | | // 格式化时间戳为 YYYY-MM-DD HH:MM:SS |
| | | const formatTimestamp = (timestamp) => { |
| | | if (!timestamp) return ''; |
| | | const date = new Date(timestamp); |
| | | const year = date.getFullYear(); |
| | | const month = String(date.getMonth() + 1).padStart(2, '0'); |
| | | const day = String(date.getDate()).padStart(2, '0'); |
| | | const hours = String(date.getHours()).padStart(2, '0'); |
| | | const minutes = String(date.getMinutes()).padStart(2, '0'); |
| | | const seconds = String(date.getSeconds()).padStart(2, '0'); |
| | | return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; |
| | | }; |
| | | |
| | | // person_type到avatar类型的映射 |
| | | // 1: 申请人, 2: 被申请人, 3: AI调解员, 4: 调解员 |
| | | const getAvatarType = (personType) => { |
| | | const typeMap = { |
| | | '1': 'ai', |
| | | '2': 'applicant', |
| | | '3': 'respondent', |
| | | '1': 'applicant', |
| | | '2': 'respondent', |
| | | '3': 'ai', |
| | | '4': 'mediator' |
| | | }; |
| | | return typeMap[personType] || 'ai'; |
| | |
| | | }; |
| | | |
| | | // 获取角色显示名称 |
| | | // 1: 申请人, 2: 被申请人, 3: AI调解员, 4: 调解员 |
| | | const getRoleDisplayName = (personType, creatorName) => { |
| | | const roleMap = { |
| | | '1': 'AI调解员', |
| | | '2': `申请人(${creatorName})`, |
| | | '3': `被申请人(${creatorName})`, |
| | | '1': `申请人(${creatorName})`, |
| | | '2': `被申请人(${creatorName})`, |
| | | '3': 'AI调解员', |
| | | '4': `调解员(${creatorName})` |
| | | }; |
| | | return roleMap[personType] || creatorName; |
| | | }; |
| | | |
| | | // 数据格式化函数 |
| | | // 数据格式化函数(保留原始数据字段用于通话记录功能) |
| | | const formatRecordData = (apiRecords) => { |
| | | return apiRecords.map(record => ({ |
| | | ...record, // 保留原始数据字段(person_id, job_id, creator等) |
| | | avatar: getAvatarType(record.person_type), |
| | | name: getRoleDisplayName(record.person_type, record.creator), |
| | | avatarText: record.creator?.charAt(0) || '', // 头像显示名字第一个字 |
| | | time: record.create_time, |
| | | avatarText: record.creator?.charAt(0) || '', |
| | | time: formatTimestamp(record.create_time), |
| | | content: record.result, |
| | | tags: record.tagList?.map(tag => ({ |
| | | text: tag.tag_name, |
| | |
| | | })); |
| | | }; |
| | | |
| | | // 获取调解记录数据 |
| | | const loadMediationRecords = async () => { |
| | | setLoading(true); |
| | | // 看板轮询间隔(毫秒) |
| | | const BOARD_POLL_INTERVAL = 5000; // 5秒 |
| | | const isBoardMountedRef = useRef(true); |
| | | |
| | | // 获取调解记录数据(首次加载带 loading,后续静默刷新) |
| | | const loadMediationRecords = useCallback(async (silent = false) => { |
| | | if (!silent) { |
| | | setLoading(true); |
| | | } |
| | | setError(null); |
| | | |
| | | try { |
| | | // 从timeline中获取mediation_id |
| | | const mediationId = timeline.mediation?.id; |
| | | if (!mediationId) { |
| | | throw new Error('未找到调解ID'); |
| | | if (!silent) throw new Error('未找到调解ID'); |
| | | return; |
| | | } |
| | | |
| | | // 调用API获取记录列表 |
| | |
| | | |
| | | // 格式化数据 |
| | | const formattedRecords = formatRecordData(response.data || []); |
| | | setRecords(formattedRecords); |
| | | if (isBoardMountedRef.current) { |
| | | setRecords(formattedRecords); |
| | | } |
| | | |
| | | } catch (err) { |
| | | setError(err.message); |
| | | console.error('获取调解记录失败:', err); |
| | | message.error(`获取调解记录失败: ${err.message}`); |
| | | if (!silent) { |
| | | setError(err.message); |
| | | console.error('获取调解记录失败:', err); |
| | | message.error(`获取调解记录失败: ${err.message}`); |
| | | } else { |
| | | console.warn('[MediationBoard] 静默刷新失败:', err.message); |
| | | } |
| | | } finally { |
| | | setLoading(false); |
| | | if (!silent) { |
| | | setLoading(false); |
| | | } |
| | | } |
| | | }; |
| | | }, [timeline.mediation?.id]); |
| | | |
| | | // 监听Tab切换 |
| | | // Tab激活时:首次加载 + 启动周期性轮询 |
| | | useEffect(() => { |
| | | isBoardMountedRef.current = true; |
| | | |
| | | if (activeTab === 'mediation-board') { |
| | | loadMediationRecords(); |
| | | // 首次加载(带 loading 效果) |
| | | loadMediationRecords(false); |
| | | |
| | | // 周期性静默刷新(不显示 loading) |
| | | const pollTimer = setInterval(() => { |
| | | if (isBoardMountedRef.current) { |
| | | loadMediationRecords(true); |
| | | } |
| | | }, BOARD_POLL_INTERVAL); |
| | | |
| | | console.log('[MediationBoard] 启动周期轮询,间隔:', BOARD_POLL_INTERVAL, 'ms'); |
| | | |
| | | return () => { |
| | | clearInterval(pollTimer); |
| | | isBoardMountedRef.current = false; |
| | | }; |
| | | } |
| | | }, [activeTab]); |
| | | |
| | | return () => { isBoardMountedRef.current = false; }; |
| | | }, [activeTab, loadMediationRecords]); |
| | | |
| | | // 如果还在加载中,显示Loading状态 |
| | | if (loading) { |
| | |
| | | return avatarText || (avatar === 'applicant' ? '申' : avatar === 'respondent' ? '被' : '调'); |
| | | }; |
| | | |
| | | // 状态控制按钮显示逻辑 |
| | | const shouldShowControlButton = () => { |
| | | return caseState === 0 || caseState === 1 || caseState === 5; |
| | | }; |
| | | |
| | | const getControlButtonProps = () => { |
| | | if (caseState === 0 || caseState === 1) { |
| | | return { |
| | | text: '终止', |
| | | style: 'terminate', |
| | | action: 'terminate' |
| | | }; |
| | | } else if (caseState === 5) { |
| | | return { |
| | | text: '恢复', |
| | | style: 'resume', |
| | | action: 'resume' |
| | | }; |
| | | } |
| | | return null; |
| | | }; |
| | | |
| | | // 处理状态控制按钮点击 |
| | | const handleControlButtonClick = (action) => { |
| | | setControlAction(action); |
| | | setConfirmModalVisible(true); |
| | | }; |
| | | |
| | | // 处理确认对话框确认 |
| | | const handleConfirmOk = async () => { |
| | | if (!controlAction) return; |
| | | |
| | | setControlLoading(true); |
| | | try { |
| | | const params = getMergedParams(); |
| | | const actionCode = controlAction === 'terminate' ? 0 : 1; |
| | | |
| | | await ProcessAPIService.updateMediationState(params.caseId, { |
| | | action: actionCode, |
| | | userName: localStorage.getItem('userName') || '调解员' |
| | | }); |
| | | |
| | | message.success('案件状态更新成功'); |
| | | setConfirmModalVisible(false); |
| | | setRemark(''); |
| | | |
| | | // 刷新数据 |
| | | refreshData(); |
| | | } catch (error) { |
| | | console.error('状态更新失败:', error); |
| | | message.error(error.message || '状态更新失败,请稍后重试'); |
| | | } finally { |
| | | setControlLoading(false); |
| | | } |
| | | }; |
| | | |
| | | // 处理确认对话框取消 |
| | | const handleConfirmCancel = () => { |
| | | setConfirmModalVisible(false); |
| | | setControlAction(null); |
| | | setRemark(''); |
| | | }; |
| | | return ( |
| | | <> |
| | | <div className="mediation-summary" style={{ |
| | |
| | | {getAvatarContent(item.avatar, item.avatarText)} |
| | | </div> |
| | | <div className="item-source"> |
| | | <div style={{ fontWeight: 600, fontSize: '0.95rem', color: 'var(--dark-color)', marginBottom: 2 }}>{item.name}</div> |
| | | <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 2 }}> |
| | | <span style={{ fontWeight: 600, fontSize: '0.95rem', color: 'var(--dark-color)' }}>{item.name}</span> |
| | | {item.avatar !== 'ai' && item.avatar !== 'mediator' && ( |
| | | <span |
| | | className="call-record-btn" |
| | | style={{ |
| | | display: 'inline-flex', |
| | | alignItems: 'center', |
| | | gap: 4, |
| | | padding: '2px 8px', |
| | | fontSize: '0.75rem', |
| | | background: '#e3f2fd', |
| | | color: '#1890ff', |
| | | borderRadius: 12, |
| | | cursor: 'pointer', |
| | | transition: 'all 0.2s' |
| | | }} |
| | | onClick={(e) => { |
| | | e.stopPropagation(); |
| | | setCurrentRecord(item); |
| | | setCallRecordVisible(true); |
| | | }} |
| | | onMouseEnter={(e) => { |
| | | e.target.style.background = '#bbdefb'; |
| | | }} |
| | | onMouseLeave={(e) => { |
| | | e.target.style.background = '#e3f2fd'; |
| | | }} |
| | | > |
| | | <PhoneOutlined style={{ fontSize: 12 }} /> |
| | | 通话记录 |
| | | </span> |
| | | )} |
| | | </div> |
| | | <div style={{ fontSize: '0.8rem', color: 'var(--gray-color)', display: 'flex', alignItems: 'center', gap: 6 }}> |
| | | <i className="far fa-clock"></i> |
| | | <span>{item.time}</span> |
| | |
| | | </div> |
| | | ))} |
| | | </div> |
| | | |
| | | {/* 状态控制按钮区域 */} |
| | | {shouldShowControlButton() && (() => { |
| | | const buttonProps = getControlButtonProps(); |
| | | if (!buttonProps) return null; |
| | | |
| | | return ( |
| | | <div style={{ |
| | | marginTop: 20, |
| | | paddingTop: 15, |
| | | borderTop: '1px solid #e9ecef', |
| | | display: 'flex', |
| | | justifyContent: 'center', |
| | | gap: 12 |
| | | }}> |
| | | <button |
| | | onClick={() => handleControlButtonClick(buttonProps.action)} |
| | | disabled={controlLoading} |
| | | style={{ |
| | | padding: '10px 20px', |
| | | borderRadius: 'var(--border-radius)', |
| | | fontWeight: 600, |
| | | fontSize: '0.9rem', |
| | | cursor: controlLoading ? 'not-allowed' : 'pointer', |
| | | display: 'flex', |
| | | alignItems: 'center', |
| | | gap: 6, |
| | | border: 'none', |
| | | ...(buttonProps.style === 'terminate' ? { |
| | | background: '#1A6FB8', |
| | | color: 'white', |
| | | } : { |
| | | background: '#52c41a', |
| | | color: 'white', |
| | | }), |
| | | opacity: controlLoading ? 0.6 : 1, |
| | | }} |
| | | > |
| | | {controlLoading ? ( |
| | | <><i className="fas fa-spinner fa-spin"></i>处理中...</> |
| | | ) : ( |
| | | <><i className="fas fa-pause-circle"></i>{buttonProps.text}</> |
| | | )} |
| | | </button> |
| | | </div> |
| | | ); |
| | | })()} |
| | | |
| | | {/* 状态控制确认对话框 */} |
| | | <Modal |
| | | title={controlAction === 'terminate' ? '确认终止调解' : '确认恢复调解'} |
| | | visible={confirmModalVisible} |
| | | onOk={handleConfirmOk} |
| | | onCancel={handleConfirmCancel} |
| | | okText="确定" |
| | | cancelText="取消" |
| | | confirmLoading={controlLoading} |
| | | > |
| | | <p> |
| | | {controlAction === 'terminate' |
| | | ? '确定要终止当前AI调解流程吗?终止后调解将暂停,可在适当时机恢复。' |
| | | : '确定要恢复AI调解流程吗?恢复后将从当前位置继续调解。'} |
| | | </p> |
| | | <div style={{ marginTop: 15 }}> |
| | | <label style={{ display: 'block', marginBottom: 5, fontWeight: 500 }}> |
| | | 备注(可选): |
| | | </label> |
| | | <Input.TextArea |
| | | value={remark} |
| | | onChange={(e) => setRemark(e.target.value)} |
| | | placeholder="请输入操作备注..." |
| | | rows={3} |
| | | /> |
| | | </div> |
| | | </Modal> |
| | | |
| | | {/* 通话记录弹窗 */} |
| | | <CallRecordModal |
| | | visible={callRecordVisible} |
| | | onClose={() => setCallRecordVisible(false)} |
| | | record={currentRecord} |
| | | /> |
| | | </> |
| | | ); |
| | | }; |
| | |
| | | }; |
| | | |
| | | export default TabContainer; |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |