8 files added
12 files modified
22265 ■■■■■ changed files
cloud-melody-front-build.zip patch | view | raw | blame | history
document/原型/index.html 2 ●●● patch | view | raw | blame | history
web-app/deploy/cloud-melody-front.conf 52 ●●●●● patch | view | raw | blame | history
web-app/deploy/deploy.bat 161 ●●●●● patch | view | raw | blame | history
web-app/deploy/deploy.ps1 189 ●●●●● patch | view | raw | blame | history
web-app/deploy/deploy.sh 152 ●●●●● patch | view | raw | blame | history
web-app/index.html 1 ●●●● patch | view | raw | blame | history
web-app/nginx-melody.conf 111 ●●●●● patch | view | raw | blame | history
web-app/package-lock.json 18009 ●●●●● patch | view | raw | blame | history
web-app/src/components/common/OutboundCallWidget.jsx 209 ●●●● patch | view | raw | blame | history
web-app/src/components/dashboard/TabContainer.jsx 143 ●●●● patch | view | raw | blame | history
web-app/src/components/dashboard/TopSection.jsx 4 ●●●● patch | view | raw | blame | history
web-app/src/config/env.js 12 ●●●● patch | view | raw | blame | history
web-app/src/contexts/CaseDataContext.jsx 192 ●●●● patch | view | raw | blame | history
web-app/src/services/EvidenceAPIService.js 15 ●●●●● patch | view | raw | blame | history
web-app/src/services/MediationAgreementAPIService.js 2 ●●● patch | view | raw | blame | history
web-app/src/services/OutboundBotAPIService.js 10 ●●●●● patch | view | raw | blame | history
web-app/src/services/ProcessAPIService.js 25 ●●●● patch | view | raw | blame | history
web-app/src/utils/urlParams.js 6 ●●●● patch | view | raw | blame | history
web-app/yarn.lock 2970 ●●●● patch | view | raw | blame | history
cloud-melody-front-build.zip
Binary files differ
document/原型/index.html
@@ -3,7 +3,7 @@
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>"解纷智能体"劳动争议AI调解智能体</title>
        <title>白云区人和镇劳动争议"解纷智能体"</title>
        <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
        <style>
            :root {
web-app/deploy/cloud-melody-front.conf
New file
@@ -0,0 +1,52 @@
# 云小调前端 Nginx 配置
# 服务器:36.140.67.217
# 后端API:http://36.140.67.217:9015
server {
    listen 9002;
    server_name 36.140.67.217;
    # 前端静态资源
    root /var/www/cloud-melody-front;
    index index.html;
    # 开启 gzip 压缩
    gzip on;
    gzip_vary on;
    gzip_min_length 1024;
    gzip_proxied any;
    gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml;
    gzip_comp_level 6;
    # 静态资源缓存
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
        expires 30d;
        add_header Cache-Control "public, immutable";
    }
    # API 代理转发到后端 Python 服务
    # 前端请求 /api/v1/xxx -> 后端 http://127.0.0.1:9015/api/v1/xxx
    location /api/ {
        proxy_pass http://127.0.0.1:9015;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        # 超时设置
        proxy_connect_timeout 60s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;
    }
    # React Router 支持 - 所有路由返回 index.html
    location / {
        try_files $uri $uri/ /index.html;
    }
    # 禁止访问隐藏文件
    location ~ /\. {
        deny all;
    }
}
web-app/deploy/deploy.bat
New file
@@ -0,0 +1,161 @@
@echo off
chcp 65001 >nul
setlocal enabledelayedexpansion
:: ============================================
:: Cloud Melody Frontend Deploy Script (Windows)
:: Target Server: 36.140.67.217
:: ============================================
set SERVER_IP=36.140.67.217
set SERVER_USER=root
set DEPLOY_PATH=/var/www/cloud-melody-front
set NGINX_CONF_NAME=cloud-melody-front.conf
set "GREEN=[92m"
set "YELLOW=[93m"
set "RED=[91m"
set "NC=[0m"
cd /d "%~dp0"
echo.
echo ============================================
echo     Cloud Melody Frontend Deploy Script
echo ============================================
echo.
:: Check ssh and scp
where ssh >nul 2>&1
if %errorlevel% neq 0 (
    echo %RED%[ERROR]%NC% ssh command not found
    echo Please enable OpenSSH Client in Windows Settings
    pause
    exit /b 1
)
where scp >nul 2>&1
if %errorlevel% neq 0 (
    echo %RED%[ERROR]%NC% scp command not found
    echo Please enable OpenSSH Client in Windows Settings
    pause
    exit /b 1
)
:: Check build directory
if not exist "..\build" (
    echo %YELLOW%[INFO]%NC% build directory not found, building project...
    call :build_project
)
:: Deploy
call :deploy_to_server
if %errorlevel% equ 0 (
    call :show_result
)
pause
exit /b 0
:: ============================================
:: Build Project
:: ============================================
:build_project
echo %GREEN%[INFO]%NC% Building project...
cd /d "%~dp0.."
where npm >nul 2>&1
if %errorlevel% neq 0 (
    echo %RED%[ERROR]%NC% npm not found, please install Node.js
    pause
    exit /b 1
)
echo %GREEN%[INFO]%NC% Installing dependencies...
call npm install
echo %GREEN%[INFO]%NC% Building production bundle...
call npm run build
if not exist "build" (
    echo %RED%[ERROR]%NC% Build failed, build directory not found
    pause
    exit /b 1
)
cd /d "%~dp0"
echo %GREEN%[INFO]%NC% Build completed
goto :eof
:: ============================================
:: Deploy to Server
:: ============================================
:deploy_to_server
echo.
echo %GREEN%[INFO]%NC% Starting deployment to %SERVER_IP%...
echo.
echo %YELLOW%[IMPORTANT]%NC% You will be prompted for password: pe0DahXt2#
echo.
:: Create remote directories
echo %GREEN%[INFO]%NC% Creating remote directories...
ssh %SERVER_USER%@%SERVER_IP% "mkdir -p %DEPLOY_PATH% && mkdir -p /etc/nginx/conf.d"
if %errorlevel% neq 0 (
    echo %RED%[ERROR]%NC% Failed to create remote directories
    exit /b 1
)
:: Upload build files
echo %GREEN%[INFO]%NC% Uploading build files...
scp -r ..\build\* %SERVER_USER%@%SERVER_IP%:%DEPLOY_PATH%/
if %errorlevel% neq 0 (
    echo %RED%[ERROR]%NC% Failed to upload build files
    exit /b 1
)
:: Upload nginx config
echo %GREEN%[INFO]%NC% Uploading Nginx config...
scp cloud-melody-front.conf %SERVER_USER%@%SERVER_IP%:/etc/nginx/conf.d/
if %errorlevel% neq 0 (
    echo %RED%[ERROR]%NC% Failed to upload Nginx config
    exit /b 1
)
:: Restart nginx
echo %GREEN%[INFO]%NC% Configuring and restarting Nginx...
ssh %SERVER_USER%@%SERVER_IP% "nginx -t && systemctl restart nginx && systemctl enable nginx"
if %errorlevel% neq 0 (
    echo %RED%[ERROR]%NC% Failed to restart Nginx
    exit /b 1
)
echo.
echo %GREEN%[INFO]%NC% Deployment completed!
goto :eof
:: ============================================
:: Show Result
:: ============================================
:show_result
echo.
echo ============================================
echo %GREEN%Deployment Successful!%NC%
echo ============================================
echo.
echo Access URLs:
echo   Frontend: http://%SERVER_IP%:9002
echo   Backend API: http://%SERVER_IP%:9015
echo.
echo Server Info:
echo   IP: %SERVER_IP%
echo   User: %SERVER_USER%
echo   Deploy Path: %DEPLOY_PATH%
echo.
echo Useful Commands:
echo   Check Nginx status: ssh %SERVER_USER%@%SERVER_IP% "systemctl status nginx"
echo   View Nginx logs: ssh %SERVER_USER%@%SERVER_IP% "tail -f /var/log/nginx/error.log"
echo   Restart Nginx: ssh %SERVER_USER%@%SERVER_IP% "systemctl restart nginx"
echo ============================================
goto :eof
web-app/deploy/deploy.ps1
New file
@@ -0,0 +1,189 @@
# ============================================
# Cloud Melody Frontend Deploy Script
# Target Server: 36.140.67.217
# ============================================
$SERVER_IP = "36.140.67.217"
$SERVER_USER = "root"
$SERVER_PASS = "pe0DahXt2#"
$DEPLOY_PATH = "/var/www/cloud-melody-front"
$NGINX_CONF_NAME = "cloud-melody-front.conf"
$SCRIPT_DIR = Split-Path -Parent $MyInvocation.MyCommand.Path
$BUILD_DIR = Join-Path $SCRIPT_DIR "..\build"
$NGINX_CONF = Join-Path $SCRIPT_DIR $NGINX_CONF_NAME
function Write-Info { Write-Host "[INFO] $args" -ForegroundColor Green }
function Write-Warn { Write-Host "[WARN] $args" -ForegroundColor Yellow }
function Write-Err { Write-Host "[ERROR] $args" -ForegroundColor Red }
function Install-PoshSSH {
    if (-not (Get-Module -ListAvailable -Name Posh-SSH)) {
        Write-Info "Installing Posh-SSH module..."
        try {
            Install-Module -Name Posh-SSH -Force -Scope CurrentUser -ErrorAction Stop
            Write-Info "Posh-SSH module installed successfully"
        }
        catch {
            Write-Err "Failed to install Posh-SSH: $_"
            exit 1
        }
    }
    Import-Module Posh-SSH -ErrorAction Stop
}
function New-SSHSessionWithPassword {
    param($Server, $User, $Password)
    $securePass = ConvertTo-SecureString $Password -AsPlainText -Force
    $credential = New-Object System.Management.Automation.PSCredential($User, $securePass)
    try {
        $session = New-SSHSession -ComputerName $Server -Credential $credential -AcceptKey -ErrorAction Stop
        return $session
    }
    catch {
        Write-Err "SSH connection failed: $_"
        exit 1
    }
}
function Invoke-RemoteCommand {
    param($Session, $Command)
    $result = Invoke-SSHCommand -SessionId $Session.SessionId -Command $Command
    if ($result.ExitStatus -ne 0) {
        Write-Warn "Command returned non-zero status: $Command"
        if ($result.Error) {
            Write-Host $result.Error
        }
    }
    return $result
}
function Upload-Directory {
    param($LocalDir, $RemotePath)
    Write-Info "Uploading files to $RemotePath ..."
    $securePass = ConvertTo-SecureString $SERVER_PASS -AsPlainText -Force
    $credential = New-Object System.Management.Automation.PSCredential($SERVER_USER, $securePass)
    # Create SFTP session
    $sftp = New-SFTPSession -ComputerName $SERVER_IP -Credential $credential -AcceptKey -ErrorAction Stop
    # Get all files
    $files = Get-ChildItem -Path $LocalDir -Recurse -File
    $totalFiles = $files.Count
    $currentFile = 0
    foreach ($file in $files) {
        $currentFile++
        $relativePath = $file.FullName.Substring($LocalDir.Length + 1)
        $relativePath = $relativePath.Replace('\', '/')
        $remoteFilePath = "$RemotePath/$relativePath"
        Write-Progress -Activity "Uploading files" -Status "$currentFile / $totalFiles - $relativePath" -PercentComplete (($currentFile / $totalFiles) * 100)
        # Create remote directory if needed
        $remoteDir = Split-Path $remoteFilePath -Parent
        try {
            # Build directory path
            $dirPath = ""
            $remoteDir.Split('/') | ForEach-Object {
                if ($_ -and $_ -ne '') {
                    $dirPath += "/$_"
                    try {
                        New-SFTPDirectory -SessionId $sftp.SessionId -Path $dirPath -ErrorAction SilentlyContinue | Out-Null
                    }
                    catch { }
                }
            }
        }
        catch { }
        # Upload file using correct cmdlet
        try {
            # Try Set-SFTPItem (newer Posh-SSH)
            Set-SFTPItem -SessionId $sftp.SessionId -Destination $remoteDir -Path $file.FullName -ErrorAction Stop
        }
        catch {
            # Fallback: use Set-SFTPFile (older Posh-SSH)
            try {
                Set-SFTPFile -SessionId $sftp.SessionId -RemoteFile $remoteFilePath -LocalFile $file.FullName -ErrorAction Stop
            }
            catch {
                # Last resort: use SCP via ssh command
                Write-Warn "SFTP upload failed for $relativePath, trying alternative method..."
            }
        }
    }
    Remove-SFTPSession -SessionId $sftp.SessionId | Out-Null
    Write-Progress -Activity "Uploading files" -Completed
    Write-Info "File upload completed"
}
function Upload-File {
    param($LocalFile, $RemotePath)
    $securePass = ConvertTo-SecureString $SERVER_PASS -AsPlainText -Force
    $credential = New-Object System.Management.Automation.PSCredential($SERVER_USER, $securePass)
    try {
        # Try Set-SCPItem (newer Posh-SSH)
        Set-SCPItem -ComputerName $SERVER_IP -Credential $credential -Destination $RemotePath -Path $LocalFile -AcceptKey -ErrorAction Stop
    }
    catch {
        # Fallback: use Set-SCPFile
        try {
            Set-SCPFile -ComputerName $SERVER_IP -Credential $credential -RemotePath $RemotePath -LocalFile $LocalFile -AcceptKey -ErrorAction Stop
        }
        catch {
            Write-Warn "SCP upload failed: $_"
        }
    }
}
function Main {
    Write-Host ""
    Write-Host "============================================" -ForegroundColor Cyan
    Write-Host "    Cloud Melody Frontend Deploy Script" -ForegroundColor Cyan
    Write-Host "============================================" -ForegroundColor Cyan
    Write-Host ""
    Install-PoshSSH
    Write-Info "Connecting to server $SERVER_IP ..."
    $session = New-SSHSessionWithPassword -Server $SERVER_IP -User $SERVER_USER -Password $SERVER_PASS
    Write-Info "SSH connected successfully"
    Write-Info "Creating remote directories..."
    Invoke-RemoteCommand -Session $session -Command "mkdir -p $DEPLOY_PATH"
    Invoke-RemoteCommand -Session $session -Command "mkdir -p /etc/nginx/conf.d"
    # Upload build files using SCP command (more reliable)
    Write-Info "Uploading build files..."
    $buildPath = (Resolve-Path $BUILD_DIR).Path
    # Use Windows built-in scp with sshpass alternative
    # Since Windows doesn't have sshpass, we use Posh-SSH SFTP
    Upload-Directory -LocalDir $buildPath -RemotePath $DEPLOY_PATH
    Write-Info "Uploading Nginx config..."
    Upload-File -LocalFile (Resolve-Path $NGINX_CONF) -RemotePath "/etc/nginx/conf.d/"
    Write-Info "Configuring and restarting Nginx..."
    Invoke-RemoteCommand -Session $session -Command "nginx -t"
    Invoke-RemoteCommand -Session $session -Command "systemctl restart nginx || service nginx restart"
    Invoke-RemoteCommand -Session $session -Command "systemctl enable nginx || chkconfig nginx on"
    Remove-SSHSession -SessionId $session.SessionId | Out-Null
    Write-Host ""
    Write-Info "Deployment completed!"
    exit 0
}
Main
web-app/deploy/deploy.sh
New file
@@ -0,0 +1,152 @@
#!/bin/bash
# ============================================
# 云小调前端一键部署脚本
# 目标服务器:36.140.67.217
# 使用方式:./deploy.sh
# ============================================
set -e
# 配置变量
SERVER_IP="36.140.67.217"
SERVER_USER="root"
SERVER_PASS="pe0DahXt2#"
DEPLOY_PATH="/var/www/cloud-melody-front"
NGINX_CONF_NAME="cloud-melody-front.conf"
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log_info() {
    echo -e "${GREEN}[INFO]${NC} $1"
}
log_warn() {
    echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
    echo -e "${RED}[ERROR]${NC} $1"
}
# 检查依赖
check_dependencies() {
    log_info "检查依赖..."
    if ! command -v npm &> /dev/null; then
        log_error "npm 未安装,请先安装 Node.js"
        exit 1
    fi
    if ! command -v sshpass &> /dev/null; then
        log_warn "sshpass 未安装,正在安装..."
        if [[ "$OSTYPE" == "darwin"* ]]; then
            brew install sshpass 2>/dev/null || brew install hudochenkov/sshpass/sshpass
        elif [[ "$OSTYPE" == "linux-gnu"* ]]; then
            sudo apt-get install -y sshpass || sudo yum install -y sshpass
        fi
    fi
    log_info "依赖检查完成"
}
# 构建项目
build_project() {
    log_info "开始构建项目..."
    cd "$(dirname "$0")/.."
    # 安装依赖
    log_info "安装依赖..."
    npm install
    # 构建生产包
    log_info "构建生产包..."
    npm run build
    if [ ! -d "build" ]; then
        log_error "构建失败,build 目录不存在"
        exit 1
    fi
    log_info "构建完成"
}
# 部署到服务器
deploy_to_server() {
    log_info "开始部署到服务器 ${SERVER_IP}..."
    # 创建远程目录
    sshpass -p "${SERVER_PASS}" ssh -o StrictHostKeyChecking=no ${SERVER_USER}@${SERVER_IP} "
        mkdir -p ${DEPLOY_PATH}
        mkdir -p /etc/nginx/conf.d
    "
    # 上传构建文件
    log_info "上传构建文件..."
    sshpass -p "${SERVER_PASS}" scp -o StrictHostKeyChecking=no -r build/* ${SERVER_USER}@${SERVER_IP}:${DEPLOY_PATH}/
    # 上传 nginx 配置
    log_info "配置 Nginx..."
    sshpass -p "${SERVER_PASS}" scp -o StrictHostKeyChecking=no \
        "$(dirname "$0")/cloud-melody-front.conf" \
        ${SERVER_USER}@${SERVER_IP}:/etc/nginx/conf.d/${NGINX_CONF_NAME}
    # 重启 nginx
    log_info "重启 Nginx..."
    sshpass -p "${SERVER_PASS}" ssh -o StrictHostKeyChecking=no ${SERVER_USER}@${SERVER_IP} "
        # 检查 nginx 配置
        nginx -t
        # 重启 nginx
        systemctl restart nginx || service nginx restart
        # 设置开机自启
        systemctl enable nginx || chkconfig nginx on
    "
    log_info "部署完成!"
}
# 显示部署结果
show_result() {
    echo ""
    echo "============================================"
    echo -e "${GREEN}部署成功!${NC}"
    echo "============================================"
    echo ""
    echo "访问地址:"
    echo "  前端页面:http://${SERVER_IP}:9002"
    echo "  后端API:http://${SERVER_IP}:9015"
    echo ""
    echo "服务器信息:"
    echo "  IP:${SERVER_IP}"
    echo "  用户:${SERVER_USER}"
    echo "  部署路径:${DEPLOY_PATH}"
    echo ""
    echo "常用命令:"
    echo "  查看 Nginx 状态:ssh root@${SERVER_IP} 'systemctl status nginx'"
    echo "  查看 Nginx 日志:ssh root@${SERVER_IP} 'tail -f /var/log/nginx/error.log'"
    echo "  重启 Nginx:ssh root@${SERVER_IP} 'systemctl restart nginx'"
    echo "============================================"
}
# 主流程
main() {
    echo ""
    echo "============================================"
    echo "    云小调前端一键部署脚本"
    echo "============================================"
    echo ""
    check_dependencies
    build_project
    deploy_to_server
    show_result
}
main "$@"
web-app/index.html
New file
@@ -0,0 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1"/><meta name="theme-color" content="#000000"/><meta name="description" content="Web site created using create-react-app"/><link rel="apple-touch-icon" href="/logo192.png"/><link rel="manifest" href="/manifest.json"/><title>React App</title><script defer="defer" src="/static/js/main.feba8a67.js"></script><link href="/static/css/main.fcdca725.css" rel="stylesheet"></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root"></div></body></html>
web-app/nginx-melody.conf
New file
@@ -0,0 +1,111 @@
# ============================================
# 客户系统 Nginx配置文件
# 服务器: 36.140.67.217
# 部署路径: /var/www/customer-system
#
# 安装方法:
# 1. 将此文件复制到服务器: /etc/nginx/conf.d/customer-system.conf
# 2. 测试配置: nginx -t
# 3. 重启服务: systemctl restart nginx
# ============================================
server {
    listen 9002;
    server_name 36.140.67.217 localhost;
    # 网站根目录
    root /deploy/code/front;
    index index.html index.htm;
    # 日志配置
    access_log /var/log/nginx/customer-system.access.log;
    error_log /var/log/nginx/customer-system.error.log;
    # Gzip压缩配置
    gzip on;
    gzip_vary on;
    gzip_min_length 1024;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_types text/plain text/css text/xml text/javascript
               application/javascript application/xml application/json
               application/x-font-ttf application/x-font-woff
               image/svg+xml;
    # 静态资源缓存 - JS/CSS/字体/图片长期缓存
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|otf)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
        # 允许跨域访问字体文件
        add_header Access-Control-Allow-Origin *;
    }
    # HTML文件不缓存 - 确保用户总是获取最新版本
    location ~* \.html$ {
        expires -1;
        add_header Cache-Control "no-cache, no-store, must-revalidate";
        add_header Pragma "no-cache";
    }
    location /api/ {
        proxy_pass http://127.0.0.1:9031;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
    # SPA单页应用路由支持
    # 所有路由都返回index.html,由前端路由处理
    location / {
        try_files $uri $uri/ /index.html;
    }
    # 安全头配置
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    # 禁止访问隐藏文件
    location ~ /\. {
        deny all;
        access_log off;
        log_not_found off;
    }
    # 错误页面
    error_page 404 /index.html;
    error_page 500 502 503 504 /50x.html;
    location = /50x.html {
        root /usr/share/nginx/html;
    }
}
# ============================================
# HTTPS配置 (可选 - 需要SSL证书)
# ============================================
# server {
#     listen 443 ssl http2;
#     server_name 36.140.67.217;
#
#     ssl_certificate /etc/nginx/ssl/customer-system.crt;
#     ssl_certificate_key /etc/nginx/ssl/customer-system.key;
#     ssl_session_timeout 1d;
#     ssl_session_cache shared:SSL:50m;
#     ssl_protocols TLSv1.2 TLSv1.3;
#     ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
#     ssl_prefer_server_ciphers off;
#
#     # ... 其他配置同上
# }
#
# # HTTP重定向到HTTPS
# server {
#     listen 80;
#     server_name 36.140.67.217;
#     return 301 https://$server_name$request_uri;
# }
web-app/package-lock.json
New file
Diff too large
web-app/src/components/common/OutboundCallWidget.jsx
@@ -5,11 +5,19 @@
const OUTBOUND_JOBS_KEY = 'outbound_call_jobs';
// 活跃状态列表(外呼进行中的状态)
const ACTIVE_STATUSES = ['Scheduling', 'InProgress', 'Calling', 'Ringing', 'Answered', 'Executing'];
// 活跃状态列表
const ACTIVE_STATUSES = ['Scheduling', 'Executing', 'Paused', 'Drafted', 'InProgress', 'Calling', 'Ringing', 'Answered'];
// Scheduling 状态 - 此状态变化不需要调用更新API
const SCHEDULING_STATUS = 'Scheduling';
const BACKEND_STATUSES = ['Scheduling', 'Executing', 'Succeeded', 'Paused', 'Failed', 'Cancelled', 'Drafted'];
const STATUS_TO_BACKEND = {
  InProgress: 'Executing',
  Calling: 'Executing',
  Ringing: 'Executing',
  Answered: 'Executing'
};
const isActiveStatus = (status) => !status || ACTIVE_STATUSES.includes(status);
/**
 * 智能外呼通话显示组件
@@ -20,11 +28,10 @@
 */
const OutboundCallWidget = ({ onSwitchTab, onRefreshData }) => {
  const { caseData } = useCaseData();
  const [isVisible, setIsVisible] = useState(false); // 默认不显示,有任务时自动显示
  const [isVisible, setIsVisible] = useState(false); // 默认隐藏
  const [isMinimized, setIsMinimized] = useState(false); // 默认展开(非最小化)
  const [calls, setCalls] = useState([]);
  const isMountedRef = useRef(true);
  const fetchCallStatusRef = useRef(null); // 用于存储最新的 fetchCallStatus 函数引用
  
  // 轮询间隔(毫秒)
  const POLL_INTERVAL = 10000; // 10秒
@@ -32,7 +39,7 @@
  // 最大重试次数
  const MAX_RETRY_COUNT = 10;
  // 获取 caseIdg
  // 获取 caseId
  const caseId = caseData?.caseId || caseData?.case_id;
  // 格式化通话时长
@@ -51,12 +58,11 @@
      // 读取成功的任务
      const storedSuccess = localStorage.getItem(OUTBOUND_JOBS_KEY);
      const successJobs = storedSuccess ? JSON.parse(storedSuccess) : [];
      console.log('读取成功任务:', successJobs.length, successJobs);
      
      // 读取失败的任务
      const storedFailed = localStorage.getItem(`${OUTBOUND_JOBS_KEY}_failed`);
      const failedJobs = storedFailed ? JSON.parse(storedFailed) : [];
      console.log('读取失败任务:', failedJobs.length, failedJobs);
      console.log('读取失败任务:', failedJobs);
      
      // 清理失败任务 - 按 errorCode 不同策略
      const now = Date.now();
@@ -104,8 +110,14 @@
        }
      });
      
      // 获取成功任务的 personId 集合
      const successPersonIds = new Set(successJobs.map(job => job.personId));
      // 过滤掉已有成功任务的 personId 对应的失败任务(成功任务优先)
      const filteredFailedJobs = uniqueFailedJobs.filter(job => !successPersonIds.has(job.personId));
      // 合并所有任务
      return [...successJobs, ...uniqueFailedJobs];
      return [...successJobs, ...filteredFailedJobs];
    } catch (err) {
      console.error('读取外呼任务失败:', err);
      return [];
@@ -141,11 +153,15 @@
      const results = await Promise.all(
        jobsToUpdate.map(async (job) => {
          try {
            const statusToUpdate = job.backendStatus || job.newStatus;
            if (!statusToUpdate) {
              return { success: false, job };
            }
            await OutboundBotAPIService.updateCallStatus({
              jobId: job.jobId,
              callStatus: job.newStatus
              callStatus: statusToUpdate
            });
            console.log(`状态更新成功: ${job.jobId} -> ${job.newStatus}`);
            console.log(`状态更新成功: ${job.jobId} -> ${statusToUpdate}`);
            return { success: true, job };
          } catch (err) {
            console.error(`状态更新失败: ${job.jobId}`, err);
@@ -186,7 +202,7 @@
    const now = Date.now();
    return jobs.filter(job => {
      // 检查是否为活跃状态
      if (ACTIVE_STATUSES.includes(job.callStatus)) {
      if (isActiveStatus(job.callStatus)) {
        // 检查是否超时(2小时)
        const elapsed = now - (job.pollStartTime || job.startTime || now);
        if (elapsed > 2 * 60 * 60 * 1000) {
@@ -203,45 +219,22 @@
   * 查询通话状态
   */
  const fetchCallStatus = useCallback(async () => {
    console.log('fetchCallStatus 被调用');
    // 从 localStorage 读取任务
    const storedJobs = loadJobsFromStorage();
    console.log('从 localStorage 读取的所有任务:', storedJobs);
    
    // 分离成功任务和失败任务
    const successJobs = storedJobs.filter(job => {
      const hasNoErrorCode = !job.errorCode;
      const isActiveStatus = ACTIVE_STATUSES.includes(job.callStatus);
      console.log(`任务 ${job.jobId}: errorCode=${job.errorCode}, callStatus=${job.callStatus}, 无错误码=${hasNoErrorCode}, 活跃状态=${isActiveStatus}`);
      return hasNoErrorCode && isActiveStatus;
    });
    const successJobs = storedJobs.filter(job => !job.errorCode && isActiveStatus(job.callStatus));
    const failedJobs = storedJobs.filter(job => job.errorCode > 0);
    console.log('成功任务数量:', successJobs.length, '失败任务数量:', failedJobs.length);
    
    if (successJobs.length === 0) {
      // 没有活跃任务,更新状态并返回
      if (isMountedRef.current) {
        setCalls([...failedJobs]);
        if (failedJobs.length > 0) {
        if (failedJobs.length > 0 && !isVisible) {
          setIsVisible(true);
        }
      }
      return;
    }
    // 立即显示气泡(不等待 API 返回)
    console.log('isMountedRef.current:', isMountedRef.current);
    if (isMountedRef.current) {
      // 先合并当前的成功任务和失败任务,立即显示
      const immediateJobs = [...successJobs, ...failedJobs.filter(f => !successJobs.some(s => s.personId === f.personId))];
      console.log('准备设置 calls:', immediateJobs);
      setCalls(immediateJobs);
      setIsVisible(true);
      console.log('立即显示气泡,任务数:', immediateJobs.length, 'isVisible 设置为 true');
    } else {
      console.log('组件未挂载,跳过显示气泡');
    }
    // 收集需要更新到后端的任务(状态变化且原状态不是Scheduling)
@@ -259,21 +252,26 @@
          if (response?.data) {
            const newStatus = response.data.callStatus;
            if (!newStatus) {
              return job;
            }
            const backendStatus = STATUS_TO_BACKEND[newStatus] || newStatus;
            
            // 如果状态发生变化,更新任务
            if (newStatus !== job.callStatus) {
              console.log(`任务 ${job.jobId} 状态更新: ${job.callStatus} -> ${newStatus}`);
              
              // 检查是否需要调用后端更新API(排除Scheduling状态)
              if (job.callStatus !== SCHEDULING_STATUS) {
              if (backendStatus !== SCHEDULING_STATUS && BACKEND_STATUSES.includes(backendStatus)) {
                jobsNeedBackendUpdate.push({
                  ...job,
                  newStatus
                  newStatus,
                  backendStatus
                });
              }
              
              // 如果是终态,可以从轮询中移除
              if (!ACTIVE_STATUSES.includes(newStatus)) {
              if (!isActiveStatus(newStatus)) {
                console.log(`任务 ${job.jobId} 达到终态: ${newStatus}`);
                return null; // 标记为删除
              }
@@ -328,26 +326,18 @@
    // 保存到 localStorage
    saveJobsToStorage(cleanedJobs);
    // 合并成功任务和失败任务,按 personId 去重(成功任务优先)
    const successPersonIds = new Set(cleanedJobs.map(job => job.personId));
    // 过滤掉已有成功任务的 personId 对应的失败任务
    const filteredFailedJobs = failedJobs.filter(job => !successPersonIds.has(job.personId));
    const allJobs = [...cleanedJobs, ...filteredFailedJobs];
    // 合并成功任务和失败任务
    const allJobs = [...cleanedJobs, ...failedJobs];
    // 更新组件状态
    if (isMountedRef.current) {
      setCalls(allJobs);
      // 如果有任务,显示气泡(使用函数式更新避免依赖 isVisible)
      if (allJobs.length > 0) {
      // 如果有任务,显示气泡
      if (allJobs.length > 0 && !isVisible) {
        setIsVisible(true);
      }
    }
  }, [triggerPageUpdate]);
  // 将最新的 fetchCallStatus 存储到 ref 中
  useEffect(() => {
    fetchCallStatusRef.current = fetchCallStatus;
  }, [fetchCallStatus]);
  }, [isVisible, triggerPageUpdate]);
  // 定时轮询通话状态
  useEffect(() => {
@@ -360,45 +350,20 @@
    // 设置轮询定时器(10秒间隔)
    const interval = setInterval(fetchCallStatus, POLL_INTERVAL);
    
    // 监听外呼任务更新事件(立即刷新)
    const handleOutboundJobsUpdated = () => {
      console.log('收到外呼任务更新事件,立即刷新');
      fetchCallStatus();
    };
    window.addEventListener('outbound-jobs-updated', handleOutboundJobsUpdated);
    // 清理函数
    return () => {
      clearInterval(interval);
      window.removeEventListener('outbound-jobs-updated', handleOutboundJobsUpdated);
      isMountedRef.current = false;
    };
  }, [fetchCallStatus]);
  // 监听 localStorage 变化,外呼成功后立即刷新
  useEffect(() => {
    const handleStorageChange = (e) => {
      // 监听外呼任务存储的变化
      if (e.key === OUTBOUND_JOBS_KEY || e.key === `${OUTBOUND_JOBS_KEY}_failed`) {
        console.log('localStorage 变化,刷新外呼状态');
        if (fetchCallStatusRef.current) {
          fetchCallStatusRef.current();
        }
      }
    };
    // 监听 storage 事件(跨标签页同步)
    window.addEventListener('storage', handleStorageChange);
    // 同页面内的 localStorage 变化需要手动触发
    // 创建自定义事件监听 - 使用 ref 避免依赖问题
    const handleCustomStorageChange = () => {
      console.log('同页面 localStorage 变化,刷新外呼状态');
      if (fetchCallStatusRef.current) {
        fetchCallStatusRef.current();
      }
    };
    window.addEventListener('outbound-jobs-updated', handleCustomStorageChange);
    console.log('事件监听器已设置: outbound-jobs-updated');
    return () => {
      window.removeEventListener('storage', handleStorageChange);
      window.removeEventListener('outbound-jobs-updated', handleCustomStorageChange);
    };
  }, []); // 空依赖数组,只在组件挂载时设置一次
  // 关闭气泡
  const handleClose = (e) => {
@@ -437,12 +402,8 @@
    return true;
  });
  // 添加调试日志
  console.log('渲染检查 - calls:', calls.length, 'activeCalls:', activeCalls.length, 'isVisible:', isVisible, 'isMinimized:', isMinimized);
  // 如果没有任务,不渲染任何内容
  if (activeCalls.length === 0) {
    console.log('无活跃任务,不渲染气泡');
  // 如果没有活跃任务且不可见,不渲染任何内容
  if (activeCalls.length === 0 && !isVisible) {
    return null;
  }
@@ -616,14 +577,14 @@
              {call.errorCode > 0 ? (
                // 失败任务显示
                <span>
                  {call.perClassName || '联系人'}
                  {call.perTypeName || '联系人'}
                  {call.trueName && `(${call.trueName})`}:
                  {call.message}
                </span>
              ) : (
                // 成功任务显示
                // 成功任务显示 - 使用 perTypeName 字段(申请方当事人/被申请方当事人)
                <span>
                  正在与{call.perClassName || '申请方'}({call.trueName || call.personId})电话沟通中...
                  正在与{call.perTypeName || '申请方当事人'}({call.trueName || call.personId})电话沟通中...
                </span>
              )}
            </div>
@@ -647,6 +608,64 @@
        </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 {
@@ -658,4 +677,4 @@
  );
};
export default OutboundCallWidget;
export default OutboundCallWidget;
web-app/src/components/dashboard/TabContainer.jsx
@@ -1,4 +1,4 @@
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 ProcessAPIService from '../../services/ProcessAPIService';
@@ -447,16 +447,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 +480,24 @@
    return '已审核';
  };
  // 加载数据
  const loadData = async () => {
    // 使用getMergedParams获取参数(URL参数优先,默认值兜底)
    const params = getMergedParams();
    const caseId = params.caseId;
    const caseType = params.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 +529,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 +663,7 @@
      message.success('审核通过!');
      handleCloseAuditModal();
      // 重新加载数据
      loadData();
      handleReload();
    } catch (err) {
      console.error('审核失败:', err);
      message.error('审核失败,请重试');
@@ -736,7 +701,7 @@
      message.success('材料已退回,等待补充提交');
      setReturnModalVisible(false);
      handleCloseAuditModal();
      loadData();
      handleReload();
    } catch (err) {
      console.error('退回失败:', err);
      message.error('退回失败,请重试');
@@ -1307,10 +1272,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);
@@ -1319,10 +1284,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) => {
@@ -1343,36 +1307,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 () => {
@@ -1430,9 +1370,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) {
@@ -1478,11 +1418,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('保存协议失败,请稍后重试');
@@ -1507,7 +1444,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>
web-app/src/components/dashboard/TopSection.jsx
@@ -24,11 +24,11 @@
            <img
              style={{ width: 36 }}
              src="http://gz.hugeinfo.com.cn/dyh/wx414ae04ac3f10b4e/images/pngAI_logo.png"
              alt="解纷智能体"
              // alt="云小调"
            />
          </div>
          <div className="title-text">
            <h1>"解纷智能体"劳动争议AI调解智能体</h1>
            <h1>白云区人和镇劳动争议"解纷智能体"</h1>
            <div className="title-subtitle">
              <div className="subtitle-text">AI调解员驾驶舱 - 全自动调解系统</div>
              <div className="ai-status-tag">
web-app/src/config/env.js
@@ -11,7 +11,7 @@
  PRD: 'prd'
};
// 当前环境,默认为开发环境
// 当前环境,默认为SIT测试环境
const CURRENT_ENV = process.env.REACT_APP_ENV || ENV_TYPES.DEV;
// 环境配置映射
@@ -24,10 +24,10 @@
    name: '开发环境'
  },
  [ENV_TYPES.SIT]: {
    // 集成测试环境
    baseURL: 'http://192.168.3.114:9015',
    // 集成测试环境 - 通过 Nginx 代理访问后端,避免跨域
    baseURL: '',  // 使用相对路径,通过Nginx代理转发
    timeout: 30000,
    withCredentials: true,
    withCredentials: false,
    name: '集成测试环境'
  },
  [ENV_TYPES.UAT]: {
@@ -38,8 +38,8 @@
    name: '用户验收环境'
  },
  [ENV_TYPES.PRD]: {
    // 生产环境
    baseURL: 'https://api.example.com',
    // 生产环境 - 通过 Nginx 代理访问后端
    baseURL: '',  // 使用相对路径,通过Nginx代理转发
    timeout: 30000,
    withCredentials: true,
    name: '生产环境'
web-app/src/contexts/CaseDataContext.jsx
@@ -7,6 +7,8 @@
import { message } from 'antd';
import ProcessAPIService from '../services/ProcessAPIService';
import OutboundBotAPIService from '../services/OutboundBotAPIService';
import EvidenceAPIService from '../services/EvidenceAPIService';
import MediationAgreementAPIService from '../services/MediationAgreementAPIService';
import { getMergedParams } from '../utils/urlParams';
import { mockTimelineData } from '../mocks/timeline';
import { getFallbackStartTime, parseTimeString } from '../utils/timeFormatter';
@@ -30,6 +32,21 @@
  const [taskStartTime, setTaskStartTime] = useState(null);  // 任务开始时间
  const [isTaskTimeFallback, setIsTaskTimeFallback] = useState(false);  // 是否降级模式
  const [hasLoaded, setHasLoaded] = useState(false);  // 防止重复加载
  // 证据材料数据
  const [evidenceData, setEvidenceData] = useState({
    applicantMaterials: [],
    respondentMaterials: [],
    loading: false,
    error: null
  });
  // 调解协议数据
  const [agreementData, setAgreementData] = useState({
    content: '',
    loading: false,
    error: null
  });
  /**
   * 保存数据到localStorage
@@ -52,7 +69,16 @@
      if (!stored) return false;
      
      const jobs = JSON.parse(stored);
      const activeStatuses = ['Scheduling', 'Executing', 'Paused', 'Drafted'];
      const activeStatuses = [
        'Scheduling',
        'Executing',
        'Paused',
        'Drafted',
        'InProgress',
        'Calling',
        'Ringing',
        'Answered'
      ];
      
      // 过滤出活跃状态的任务
      const activeJobs = jobs.filter(job => activeStatuses.includes(job.callStatus));
@@ -104,10 +130,6 @@
      });
      // 处理响应
      console.log('triggerOutboundCall API 响应:', response);
      console.log('response.data:', response?.data);
      console.log('response.data 是数组:', Array.isArray(response?.data));
      if (response?.data && Array.isArray(response.data)) {
        const successJobs = [];
        const failedJobs = [];
@@ -121,9 +143,10 @@
              personId: item.personId,
              mediationId: item.mediationId,
              caseId: String(caseId), // 添加 caseId 字段用于轮询
              perTypeName: item.perTypeName || '', // 当事人类型名称(申请方当事人/被申请方当事人)
              perClassName: item.perClassName || '', // 添加人员类型名称
              trueName: item.trueName || '', // 添加真实姓名
              startTime: item.createTime || item.createdTime || item.start_time,
              startTime: item.createTime || item.createdTime || item.start_time, // 兼容 createTime 和 createdTime
              pollStartTime: Date.now(),
              retryCount: 0
            });
@@ -131,6 +154,7 @@
            failedJobs.push({
              personId: item.personId,
              message: item.message || '未知错误',
              perTypeName: item.perTypeName || '', // 当事人类型名称(申请方当事人/被申请方当事人)
              perClassName: item.perClassName || '', // 添加人员类型名称
              trueName: item.trueName || '', // 添加真实姓名
              errorCode: item.errorCode, // 添加错误码
@@ -141,31 +165,16 @@
        // 存储成功的任务到 localStorage
        if (successJobs.length > 0) {
          // 读取现有的成功任务,合并而不是覆盖
          const existingJobs = JSON.parse(localStorage.getItem(OUTBOUND_JOBS_KEY) || '[]');
          // 按 jobId 去重,新任务优先
          const existingJobIds = new Set(successJobs.map(job => job.jobId));
          const filteredExistingJobs = existingJobs.filter(job => !existingJobIds.has(job.jobId));
          const mergedJobs = [...successJobs, ...filteredExistingJobs];
          // 新任务发起时,先清除所有旧的成功任务(替换而不是追加)
          localStorage.setItem(OUTBOUND_JOBS_KEY, JSON.stringify(successJobs));
          console.log('存储外呼任务成功,数量:', successJobs.length);
          
          localStorage.setItem(OUTBOUND_JOBS_KEY, JSON.stringify(mergedJobs));
          console.log('存储外呼任务成功,新增:', successJobs.length, '总数:', mergedJobs.length);
          console.log('存储的任务:', JSON.stringify(mergedJobs, null, 2));
          // 外呼成功后,清除所有失败记录(新任务开始时应清空旧记录)
          localStorage.removeItem(`${OUTBOUND_JOBS_KEY}_failed`);
          console.log('外呼成功后清除所有失败记录');
          
          // 外呼成功后,清除对应的失败记录
          const storedFailedJobs = JSON.parse(localStorage.getItem(`${OUTBOUND_JOBS_KEY}_failed`) || '[]');
          if (storedFailedJobs.length > 0) {
            // 获取成功任务的 personId 列表
            const successPersonIds = successJobs.map(job => job.personId);
            // 过滤掉已成功的 personId 对应的失败记录
            const remainingFailedJobs = storedFailedJobs.filter(job => !successPersonIds.includes(job.personId));
            localStorage.setItem(`${OUTBOUND_JOBS_KEY}_failed`, JSON.stringify(remainingFailedJobs));
            console.log('外呼成功后清除失败记录,清除数量:', storedFailedJobs.length - remainingFailedJobs.length);
          }
          // 延迟触发自定义事件,确保监听器已经设置好
          // 触发自定义事件,通知 OutboundCallWidget 组件立即刷新
          setTimeout(() => {
            console.log('触发 outbound-jobs-updated 事件');
            window.dispatchEvent(new CustomEvent('outbound-jobs-updated'));
          }, 300);
        }
@@ -197,9 +206,6 @@
          
          localStorage.setItem(`${OUTBOUND_JOBS_KEY}_failed`, JSON.stringify(cleanedFailedJobs));
          console.log('存储外呼失败任务,数量:', cleanedFailedJobs.length);
          // 触发自定义事件通知 OutboundCallWidget 组件刷新
          window.dispatchEvent(new CustomEvent('outbound-jobs-updated'));
        }
        // 提示失败的任务
@@ -257,6 +263,87 @@
      console.log('Using fallback start time:', new Date(fallbackTime).toLocaleString());
    }
  };
  /**
   * 加载证据材料数据
   * @param {Object} params - 参数对象
   * @param {string} params.caseId - 案件ID
   * @param {string} params.caseType - 案件类型
   * @param {string} params.platformCode - 平台编码
   */
  const loadEvidenceData = async (params) => {
    setEvidenceData(prev => ({ ...prev, loading: true, error: null }));
    try {
      const response = await EvidenceAPIService.getEvidenceList({
        case_id: params.caseId,
        case_type: params.caseType || params.caseTypeFirst,
        platform_code: params.platformCode
      });
      console.log('Evidence 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) || [];
      setEvidenceData({
        applicantMaterials: applicantList,
        respondentMaterials: respondentList,
        loading: false,
        error: null
      });
      console.log('Evidence data loaded:', { applicantList, respondentList });
    } catch (err) {
      console.error('加载证据材料失败:', err);
      setEvidenceData(prev => ({
        ...prev,
        loading: false,
        error: err.message || '加载证据材料失败'
      }));
    }
  };
  /**
   * 加载调解协议数据
   * @param {string} caseId - 案件ID
   */
  const loadAgreementData = async (caseId) => {
    setAgreementData(prev => ({ ...prev, loading: true, error: null }));
    try {
      const response = await MediationAgreementAPIService.generateAgreement(caseId);
      if (response?.data?.agreeContent) {
        setAgreementData({
          content: response.data.agreeContent,
          loading: false,
          error: null
        });
        console.log('Agreement data loaded');
      } else {
        setAgreementData({
          content: '',
          loading: false,
          error: '协议内容为空'
        });
      }
    } catch (err) {
      console.error('加载调解协议失败:', err);
      setAgreementData({
        content: '',
        loading: false,
        error: err.message || '加载调解协议失败'
      });
    }
  };
  /**
   * 加载案件数据
   */
@@ -276,13 +363,35 @@
      
      console.log('Loading case data with params:', params);
      if (!params.caseId) {
        setError('caseId缺失');
        setLoading(false);
        return;
      }
      EvidenceAPIService.processCaseFilesOcr(params.caseId).catch((ocrError) => {
        console.error('触发案件文件OCR失败:', ocrError);
      });
      try {
        await OutboundBotAPIService.syncStatusByCase({ caseId: params.caseId });
      } catch (syncError) {
        console.error('同步外呼状态失败:', syncError);
      }
      try {
        await OutboundBotAPIService.backfillConversationByCase({ caseId: params.caseId });
      } catch (backfillError) {
        console.error('回补通话记录失败:', backfillError);
      }
      // 调用API获取数据
      // 将URL中的auth_token转换为authorization传入API
      const response = await ProcessAPIService.getCaseProcessInfo(
        params.caseId,
        {
          caseTypeFirst: params.caseTypeFirst,
          platform_code: params.platform_code,
          authorization: params.auth_token
          authorization: params.auth_token || params.authorization || ''
        }
      );
@@ -299,6 +408,17 @@
      setCaseData(timelineData);
      setProcessNodes(Array.isArray(nodesData) ? nodesData : []);  // 确保为数组
      setHasLoaded(true);  // 标记已加载
      // 并行加载证据材料和调解协议数据(在终态检查之前,确保数据完整性)
      await Promise.all([
        loadEvidenceData({
          caseId: params.caseId,
          caseType: params.caseType || params.caseTypeFirst,
          caseTypeFirst: params.caseTypeFirst,
          platformCode: params.platform_code
        }),
        loadAgreementData(params.caseId)
      ]);
      
      // 检查终态状态(调解成功/失败/人工接管),终态不执行外呼和存储
      const mediationState = timelineData.mediation?.state;
@@ -374,7 +494,13 @@
    refreshData,
    loadCaseData,
    taskStartTime,      // 任务开始时间
    isTaskTimeFallback  // 是否降级模式
    isTaskTimeFallback, // 是否降级模式
    // 证据材料数据
    evidenceData,
    loadEvidenceData,
    // 调解协议数据
    agreementData,
    loadAgreementData
  };
  return (
web-app/src/services/EvidenceAPIService.js
@@ -8,6 +8,19 @@
class EvidenceAPIService {
  /**
   * 触发案件关联文件的 OCR 处理(后台任务)
   * POST /api/v1/case-files-ocr/process
   * @param {string|number} caseId - 案件ID
   * @returns {Promise} OCR处理结果
   */
  static processCaseFilesOcr(caseId) {
    const trimmedCaseId = String(caseId ?? '').trim();
    const formData = new FormData();
    formData.append('caseId', trimmedCaseId);
    return request.post('/api/v1/case-files-ocr/process', formData);
  }
  /**
   * 证据列表查询
   * GET /api/v1/evidence/list
   * @param {Object} params - 查询参数
@@ -190,4 +203,4 @@
  }
}
export default EvidenceAPIService;
export default EvidenceAPIService;
web-app/src/services/MediationAgreementAPIService.js
@@ -68,7 +68,7 @@
   * @returns {Promise} 重新生成的协议信息(包含新的agreeId和agreeContent)
   */
  static regenerateAgreement(caseId) {
    return request.post('/api/v1/medi-agreement/regenerate', { caseId });
    return request.post('/api/v1/medi-agreement/regenerate', { caseId }, { timeout: 30000 });
  }
}
web-app/src/services/OutboundBotAPIService.js
@@ -60,6 +60,14 @@
  static updateCallStatus(data) {
    return request.post('/api/v1/outbound-bot/update-status', data);
  }
  static syncStatusByCase(data) {
    return request.post('/api/v1/outbound-bot/sync-status-by-case', data);
  }
  static backfillConversationByCase(data) {
    return request.post('/api/v1/outbound-bot/backfill-conversation-by-case', data);
  }
}
export default OutboundBotAPIService;
export default OutboundBotAPIService;
web-app/src/services/ProcessAPIService.js
@@ -39,10 +39,14 @@
   * @param {Object} params - 查询参数
   * @param {string} params.caseTypeFirst - 案件一级分类
   * @param {string} params.platform_code - 外部平台编号
   * @param {string} params.authorization - 授权token
   * @returns {Promise} 调解时间线数据
   */
  static getMediationTimeline(caseId, params = {}) {
    return request.get(`/api/v1/mediation-timeline/v2/case/${caseId}`, params);
    const { authorization, ...queryParams } = params;
    queryParams.authorization = authorization;
    const config = authorization ? { headers: { Authorization: authorization } } : {};
    return request.get(`/api/v1/mediation-timeline/v2/case/${caseId}`, queryParams, config);
  }
  /**
@@ -52,10 +56,13 @@
   * @param {string} params.caseTypeFirst - 案件一级分类
   * @param {string} params.platformCode - 外部平台编号
   * @param {string} params.caseId - 案件ID
   * @param {string} params.authorization - 授权token
   * @returns {Promise} 流程节点列表
   */
  static getProcessNodes(params = {}) {
    return request.get('/api/v1/process/node', params);
    const { authorization, ...queryParams } = params;
    const config = authorization ? { headers: { Authorization: authorization } } : {};
    return request.get('/api/v1/process/node', queryParams, config);
  } 
@@ -65,6 +72,7 @@
   * @param {Object} params - 查询参数
   * @param {string} params.caseTypeFirst - 案件一级分类
   * @param {string} params.platform_code - 外部平台编号
   * @param {string} params.authorization - 授权token(来自URL的auth_token)
   * @returns {Promise} 完整流程信息
   */
  static async getCaseProcessInfo(caseId, params = {}) {
@@ -74,13 +82,18 @@
      const nodeParams = {
        caseTypeFirst: params.caseTypeFirst,
        platformCode: params.platform_code,
        caseId
        caseId,
        authorization: params.authorization
      };
      
      // 提取authorization用于子请求
      const { authorization, ...timelineParams } = params;
      console.log('Timeline params:', timelineParams);
      timelineParams.authorization = authorization;
      // 并行获取时间线和流程节点
      const promises = [
        this.getMediationTimeline(caseId, params),
        this.getProcessNodes(nodeParams)
        this.getMediationTimeline(caseId, { ...timelineParams, authorization }),
        this.getProcessNodes({ ...nodeParams, authorization })
      ];
      const results = await Promise.all(promises);
@@ -119,7 +132,7 @@
  }
  /**
   * 人工接管API·
   * 人工接管API
   * PUT /api/v1/mediation-timeline/v2/case/{caseId}/takeover
   * @param {string} caseId - 案件ID
   * @param {Object} data - 请求数据
web-app/src/utils/urlParams.js
@@ -9,7 +9,7 @@
 */
export const getDefaultParams = () => {
  return {
    caseId: '202601281644031088',
    caseId: '202602261114241000',
    caseTypeFirst: '24_01-2',
    caseType: '24_02-9',
    platform_code: 'AI_0001',
@@ -39,11 +39,11 @@
 * @returns {Object} 合并后的参数对象
 */
export const getMergedParams = () => {
  // const defaultParams = getDefaultParams();
  const defaultParams = getDefaultParams();
  const urlParams = parseUrlParams();
  
  return {
    // ...defaultParams,
    ...defaultParams,
    ...urlParams
  };
};
web-app/yarn.lock
Diff too large