chengmw
2026-04-02 6b14010e1765842acc437ab130e5815bc623788d
web-app/src/components/dashboard/TabContainer.jsx
@@ -1,11 +1,18 @@
import React, { useState, useEffect, useRef, forwardRef, useImperativeHandle } from 'react';
import React, { useState, useEffect, 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;
@@ -87,22 +94,53 @@
});
/**
 * 获取成功率同比数据
 */
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>
@@ -118,28 +156,36 @@
              {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>
  );
@@ -153,17 +199,36 @@
  const [records, setRecords] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  // 通话记录弹窗状态
  const [callRecordVisible, setCallRecordVisible] = useState(false);
  const [currentRecord, setCurrentRecord] = useState(null);
  
  // 获取案件数据
  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';
@@ -181,23 +246,25 @@
  };
  
  // 获取角色显示名称
  // 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,
@@ -393,7 +460,40 @@
                {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>
@@ -439,6 +539,13 @@
          </div>
        ))}
      </div>
      {/* 通话记录弹窗 */}
      <CallRecordModal
        visible={callRecordVisible}
        onClose={() => setCallRecordVisible(false)}
        record={currentRecord}
      />
    </>
  );
};
@@ -447,16 +554,16 @@
 * 证据材料汇总
 */
const EvidenceBoard = ({ onStatusChange }) => {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  // 从 Context 获取证据材料数据
  const { evidenceData, loadEvidenceData } = useCaseData();
  const { applicantMaterials, respondentMaterials, loading, error } = evidenceData;
  // 审核弹窗状态
  const [auditModalVisible, setAuditModalVisible] = useState(false);
  const [currentAuditItem, setCurrentAuditItem] = useState(null);
  const [auditRemark, setAuditRemark] = useState('');
  const [auditLoading, setAuditLoading] = useState(false);
  const [returnModalVisible, setReturnModalVisible] = useState(false);
  const [applicantMaterials, setApplicantMaterials] = useState([]);
  const [respondentMaterials, setRespondentMaterials] = useState([]);
  // 弹窗数据状态
  const [modalDataLoading, setModalDataLoading] = useState(false);
  const [personInfo, setPersonInfo] = useState(null);
@@ -480,59 +587,24 @@
    return '已审核';
  };
  // 加载数据
  const loadData = async () => {
    // 使用getMergedParams获取参数(URL参数优先,默认值兜底)
    const params = getMergedParams();
    const caseId = params.caseId;
    const caseType = params.caseTypeFirst;
    const platformCode = params.platform_code;
    console.log('EvidenceBoard loadData params:', { caseId, caseType, platformCode });
    setLoading(true);
    setError(null);
    try {
      // 调用API获取数据
      const response = await EvidenceAPIService.getEvidenceList({
        case_id: caseId,
        case_type: caseType,
        platform_code: platformCode
      });
      console.log('EvidenceBoard API response:', response);
      const responseData = response.data || [];
      // 分离申请人和被申请人材料
      const applicantData = responseData.find(item => item.per_type === '15_020008-1');
      const respondentData = responseData.find(item => item.per_type === '15_020008-2');
      const applicantList = applicantData?.file_list?.slice(0, applicantData.file_count) || [];
      const respondentList = respondentData?.file_list?.slice(0, respondentData.file_count) || [];
      setApplicantMaterials(applicantList);
      setRespondentMaterials(respondentList);
      // 计算并通知Tab标题的整体审核状态
      const overallStatus = calculateOverallTabStatus(applicantList, respondentList);
      if (onStatusChange) {
        onStatusChange(overallStatus);
      }
    } catch (err) {
      console.error('加载证据材料失败:', err);
      setError('数据加载失败,请稍后重试');
    } finally {
      setLoading(false);
    }
  };
  // 组件挂载时加载数据
  // 监听数据变化,通知Tab标题状态
  useEffect(() => {
    loadData();
  }, []); // eslint-disable-line react-hooks/exhaustive-deps
    const overallStatus = calculateOverallTabStatus(applicantMaterials, respondentMaterials);
    if (onStatusChange) {
      onStatusChange(overallStatus);
    }
  }, [applicantMaterials, respondentMaterials]); // eslint-disable-line react-hooks/exhaustive-deps
  // 重新加载函数
  const handleReload = () => {
    const params = getMergedParams();
    loadEvidenceData({
      caseId: params.caseId,
      caseType: params.caseType || params.caseTypeFirst,
      caseTypeFirst: params.caseTypeFirst,
      platformCode: params.platform_code
    });
  };
  // 计算区域内整体审核状态(用于卡片标题旁的Tag显示)
  const calculateOverallStatus = (materials) => {
@@ -564,7 +636,7 @@
          <i className="fas fa-exclamation-circle"></i> 数据加载失败
        </div>
        <div>{error}</div>
        <button onClick={loadData} style={{
        <button onClick={handleReload} style={{
          marginTop: 15, padding: '8px 16px', backgroundColor: '#1890ff',
          color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer'
        }}>重新加载</button>
@@ -698,7 +770,7 @@
      message.success('审核通过!');
      handleCloseAuditModal();
      // 重新加载数据
      loadData();
      handleReload();
    } catch (err) {
      console.error('审核失败:', err);
      message.error('审核失败,请重试');
@@ -736,7 +808,7 @@
      message.success('材料已退回,等待补充提交');
      setReturnModalVisible(false);
      handleCloseAuditModal();
      loadData();
      handleReload();
    } catch (err) {
      console.error('退回失败:', err);
      message.error('退回失败,请重试');
@@ -1090,9 +1162,55 @@
                            <Image.PreviewGroup>
                              <div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
                                {evidenceImages.map((img, index) => {
                                  // 判断 show_url 是否以 http 开头
                                  const isFullUrl = img.show_url && img.show_url.startsWith('http');
                                  // 如果是完整URL直接使用,否则拼接 platformUrl
                                  const imgUrl = img.show_url 
                                    ? (platformUrl ? `${platformUrl}/${img.show_url}` : img.show_url)
                                    ? (isFullUrl ? img.show_url : (platformUrl ? `${platformUrl}/${img.show_url}` : img.show_url))
                                    : '';
                                  // 获取文件名:true_name 为空时取 file_name
                                  const rawFileName = img.true_name || img.file_name || `材料${index + 1}`;
                                  // 获取文件类型后缀
                                  const suffix = img.suffix || '';
                                  // 检查文件名是否已包含后缀,避免重复
                                  const fileName = suffix && rawFileName.toLowerCase().endsWith(`.${suffix.toLowerCase()}`)
                                    ? rawFileName
                                    : (suffix ? `${rawFileName}.${suffix}` : rawFileName);
                                  const isPdf = suffix.toLowerCase() === 'pdf';
                                  if (isPdf) {
                                    // PDF文件:直接打开PDF链接
                                    return (
                                      <div
                                        key={img.file_id || img.id || index}
                                        style={{
                                          width: 100,
                                          height: 80,
                                          borderRadius: 4,
                                          overflow: 'hidden',
                                          border: '1px solid #e8e8e8',
                                          cursor: 'pointer',
                                          transition: 'all 0.2s',
                                          background: '#f5f5f5',
                                          display: 'flex',
                                          flexDirection: 'column',
                                          alignItems: 'center',
                                          justifyContent: 'center',
                                        }}
                                        onClick={() => {
                                          // 直接打开PDF
                                          window.open(imgUrl, '_blank');
                                        }}
                                      >
                                        <i className="fas fa-file-pdf" style={{ fontSize: 32, color: '#ff4d4f' }} />
                                        <span style={{ fontSize: 10, color: '#666', marginTop: 4, maxWidth: 90, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
                                          {fileName}
                                        </span>
                                      </div>
                                    );
                                  }
                                  // 图片文件:使用图片预览
                                  return (
                                    <div 
                                      key={img.file_id || img.id || index}
@@ -1108,11 +1226,18 @@
                                    >
                                      <Image
                                        src={imgUrl}
                                        alt={img.file_name || `材料${index + 1}`}
                                        alt={fileName}
                                        style={{ width: '100%', height: '100%', objectFit: 'cover' }}
                                        fallback="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjgwIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxyZWN0IHdpZHRoPSIxMDAiIGhlaWdodD0iODAiIGZpbGw9IiNmNWY1ZjUiLz48dGV4dCB4PSI1MCIgeT0iNDAiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxMCIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZmlsbD0iIzk5OSI+5Zu+54mH6aKE6KeIPC90ZXh0Pjwvc3ZnPg=="
                                        preview={{
                                          mask: <span style={{ fontSize: 12 }}>预览</span>
                                          mask: (
                                            <span style={{ fontSize: 12, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
                                              <span>预览</span>
                                              <span style={{ fontSize: 10, marginTop: 2, maxWidth: 80, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
                                                {fileName}
                                              </span>
                                            </span>
                                          )
                                        }}
                                      />
                                    </div>
@@ -1254,10 +1379,10 @@
 * 调解协议
 */
const AgreementSection = () => {
  const { caseData } = useCaseData();
  const [agreementContent, setAgreementContent] = useState('');
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  // 从 Context 获取调解协议数据
  const { caseData, agreementData, loadAgreementData } = useCaseData();
  const { content: agreementContent, loading, error } = agreementData;
  const [editModalVisible, setEditModalVisible] = useState(false);
  const [editContent, setEditContent] = useState('');
  const [editLoading, setEditLoading] = useState(false);
@@ -1266,10 +1391,9 @@
    download: false,
    regenerate: false,
  });
  const loadedRef = useRef(false);
  // 获取 caseId
  const caseId = caseData?.caseId || getMergedParams().caseId;
  // 获取 caseId(兼容驼峰和蛇形命名)
  const caseId = caseData?.caseId || caseData?.case_id || getMergedParams().caseId;
  // 处理协议内容展示(纯文本,处理换行)
  const renderAgreementContent = (content) => {
@@ -1290,36 +1414,12 @@
    });
  };
  // 首次加载协议内容
  const loadAgreement = async () => {
    if (!caseId) {
      setError('缺少案件ID,无法加载协议');
      return;
    }
    setLoading(true);
    setError(null);
    try {
      const response = await MediationAgreementAPIService.generateAgreement(caseId);
      if (response?.data?.agreeContent) {
        setAgreementContent(response.data.agreeContent);
      } else {
        setError('协议内容为空');
      }
    } catch (err) {
      console.error('加载协议失败:', err);
      setError('加载协议失败,请稍后重试');
    } finally {
      setLoading(false);
  // 重新加载协议
  const handleReload = () => {
    if (caseId) {
      loadAgreementData(caseId);
    }
  };
  // 组件挂载时加载协议
  useEffect(() => {
    if (caseId && !loadedRef.current) {
      loadedRef.current = true;
      loadAgreement();
    }
  }, [caseId]); // eslint-disable-line react-hooks/exhaustive-deps
  // 确认协议
  const handleConfirmAgreement = async () => {
@@ -1341,7 +1441,28 @@
    if (!caseId) return;
    setActionLoading(prev => ({ ...prev, download: true }));
    try {
      await MediationAgreementAPIService.downloadAgreement(caseId);
      // 调用API获取PDF文件流
      const response = await MediationAgreementAPIService.downloadAgreement(caseId);
      // 创建Blob对象(PDF格式)
      const blob = new Blob([response.data], {
        type: 'application/pdf'
      });
      // 创建下载链接
      const url = window.URL.createObjectURL(blob);
      const link = document.createElement('a');
      link.href = url;
      link.download = `调解协议_${caseId}.pdf`;
      // 触发下载
      document.body.appendChild(link);
      link.click();
      document.body.removeChild(link);
      // 清理URL对象
      window.URL.revokeObjectURL(url);
      message.success('协议下载成功!');
    } catch (err) {
      console.error('下载协议失败:', err);
@@ -1356,9 +1477,9 @@
    if (!caseId) return;
    setActionLoading(prev => ({ ...prev, regenerate: true }));
    try {
      const response = await MediationAgreementAPIService.generateAgreement(caseId);
      const response = await MediationAgreementAPIService.regenerateAgreement(caseId);
      if (response?.data?.agreeContent) {
        setAgreementContent(response.data.agreeContent);
        loadAgreementData(caseId);
        message.success('协议重新生成成功!');
      }
    } catch (err) {
@@ -1404,11 +1525,8 @@
      await MediationAgreementAPIService.updateAgreement(caseId, editContent);
      message.success('协议修改保存成功!');
      handleCloseEditModal();
      // 刷新父页面协议内容
      const response = await MediationAgreementAPIService.generateAgreement(caseId);
      if (response?.data?.agreeContent) {
        setAgreementContent(response.data.agreeContent);
      }
      // 刷新协议内容
      loadAgreementData(caseId);
    } catch (err) {
      console.error('保存协议失败:', err);
      message.error('保存协议失败,请稍后重试');
@@ -1433,7 +1551,7 @@
        <div style={{ fontSize: '1.2rem', marginBottom: 10 }}>
          <i className="fas fa-exclamation-circle"></i> {error}
        </div>
        <button onClick={loadAgreement} style={{
        <button onClick={handleReload} style={{
          marginTop: 15, padding: '8px 16px', backgroundColor: '#1890ff',
          color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer'
        }}>重新加载</button>
@@ -1647,3 +1765,10 @@
};
export default TabContainer;