diff --git a/web/src/pages/agent/Statistics.tsx b/web/src/pages/agent/Statistics.tsx index 802fd7f..52d7e8a 100644 --- a/web/src/pages/agent/Statistics.tsx +++ b/web/src/pages/agent/Statistics.tsx @@ -1,16 +1,38 @@ -import { useEffect, useState } from 'react' -import { Card, Segmented, Row, Col, Spin } from 'antd' -import { ClockCircleOutlined, SmileOutlined, CheckCircleOutlined, MessageOutlined } from '@ant-design/icons' -import { Column, Line, Pie, Bar } from '@ant-design/charts' -import { getAgentPerformance, getChannelDistribution, getKPIs, getResponseDistribution, getSessionTrend, type StatisticsKpis } from '@/services/api' +import { useEffect, useMemo, useState } from 'react' +import { Spin, message } from 'antd' +import { + ClockCircleOutlined, SmileOutlined, CheckCircleOutlined, MessageOutlined, + DownloadOutlined, ArrowUpOutlined, ArrowDownOutlined, +} from '@ant-design/icons' +import { Column, Line, Pie } from '@ant-design/charts' +import { + getAgentPerformance, getChannelDistribution, getKPIs, getResponseDistribution, getSessionTrend, + type StatisticsKpis, +} from '@/services/api' + +type TimeRange = 'today' | 'week' | 'month' + +const rangeOptions: { value: TimeRange; label: string }[] = [ + { value: 'today', label: '今日' }, + { value: 'week', label: '本周' }, + { value: 'month', label: '本月' }, +] + +const trendSubtitle: Record = { + today: '今日会话量', + week: '近7天会话量变化', + month: '近6个月会话量变化', +} const Statistics = () => { - const [timeRange, setTimeRange] = useState<'today' | 'week' | 'month'>('week') + const [timeRange, setTimeRange] = useState('week') const [kpis, setKpis] = useState(null) const [sessionTrendData, setSessionTrendData] = useState<{ date: string; count: number }[]>([]) const [responseDistribution, setResponseDistribution] = useState<{ range: string; count: number }[]>([]) const [channelData, setChannelData] = useState<{ type: string; value: number }[]>([]) - const [agentPerformance, setAgentPerformance] = useState<{ name: string; conversations: number; avgResponse: number; satisfaction: number }[]>([]) + const [agentPerformance, setAgentPerformance] = useState<{ + name: string; conversations: number; avgResponse: number; satisfaction: number + }[]>([]) const [loading, setLoading] = useState(true) useEffect(() => { @@ -19,96 +41,380 @@ const Statistics = () => { try { const period = timeRange === 'week' ? 'day' : timeRange const [kpiRes, trendRes, distributionRes, channelRes, performanceRes] = await Promise.all([ - getKPIs(), getSessionTrend(period), getResponseDistribution(), getChannelDistribution(), getAgentPerformance(), + getKPIs(), + getSessionTrend(period), + getResponseDistribution(), + getChannelDistribution(), + getAgentPerformance(), ]) setKpis(kpiRes.data) - setSessionTrendData(trendRes.data) - setResponseDistribution(distributionRes.data) - setChannelData(channelRes.data) - setAgentPerformance(performanceRes.data.map(item => ({ - name: item.name, - conversations: item.conversations, - avgResponse: item.avg_response, - satisfaction: item.satisfaction, - }))) + setSessionTrendData(Array.isArray(trendRes.data) ? trendRes.data : []) + setResponseDistribution(Array.isArray(distributionRes.data) ? distributionRes.data : []) + setChannelData(Array.isArray(channelRes.data) ? channelRes.data : []) + setAgentPerformance( + (Array.isArray(performanceRes.data) ? performanceRes.data : []).map(item => ({ + name: item.name, + conversations: item.conversations, + avgResponse: item.avg_response, + satisfaction: item.satisfaction, + })), + ) + } catch { + setKpis(null) + setSessionTrendData([]) + setResponseDistribution([]) + setChannelData([]) + setAgentPerformance([]) } finally { setLoading(false) } } - load().catch(() => { - setKpis(null) - setSessionTrendData([]) - setResponseDistribution([]) - setChannelData([]) - setAgentPerformance([]) - }) + load() }, [timeRange]) - const kpiData = [ - { label: '总会话量', value: String(kpis?.total_sessions ?? 0), icon: , color: '#2563eb' }, - { label: '平均响应时长', value: `${Math.round(kpis?.avg_response_time ?? 0)}s`, icon: , color: '#16a34a' }, - { label: '客户满意度', value: `${(kpis?.satisfaction_avg ?? 0).toFixed(1)}/5`, icon: , color: '#d97706' }, - { label: '首次解决率', value: `${(kpis?.first_resolve_rate ?? 0).toFixed(1)}%`, icon: , color: '#0891b2' }, + const satPercent = useMemo(() => { + const avg = kpis?.satisfaction_avg ?? 0 + return (avg / 5) * 100 + }, [kpis]) + + const channelTotal = useMemo( + () => channelData.reduce((s, d) => s + (d.value || 0), 0), + [channelData], + ) + + const maxConversations = useMemo( + () => Math.max(1, ...agentPerformance.map(a => a.conversations), 1), + [agentPerformance], + ) + + const kpiCards = [ + { + label: '总会话量', + value: String(kpis?.total_sessions ?? 0), + suffix: '', + hint: '基于已记录会话', + icon: , + iconBg: '#eff6ff', + iconColor: '#2563eb', + delta: null as string | null, + up: true, + }, + { + label: '平均响应时长', + value: String(Math.round(kpis?.avg_response_time ?? 0)), + suffix: '秒', + hint: '响应越短越优', + icon: , + iconBg: '#ecfeff', + iconColor: '#0891b2', + delta: null, + up: false, + }, + { + label: '客户满意度', + value: satPercent > 0 ? satPercent.toFixed(1) : '0', + suffix: '%', + hint: `评分 ${(kpis?.satisfaction_avg ?? 0).toFixed(1)}/5`, + icon: , + iconBg: '#f0fdf4', + iconColor: '#16a34a', + delta: null, + up: true, + }, + { + label: '首次解决率', + value: (kpis?.first_resolve_rate ?? 0).toFixed(1), + suffix: '%', + hint: '24h 内未再打开', + icon: , + iconBg: '#fffbeb', + iconColor: '#d97706', + delta: null, + up: true, + }, ] + const lineConfig = { + data: sessionTrendData, + xField: 'date', + yField: 'count', + smooth: true, + height: 260, + color: '#2563eb', + point: { size: 3 }, + style: { lineWidth: 2 }, + axis: { + y: { grid: true, gridStroke: '#f1f5f9' }, + x: { labelAutoHide: true }, + }, + tooltip: { channel: 'y' as const }, + } + + const columnConfig = { + data: responseDistribution, + xField: 'range', + yField: 'count', + height: 260, + color: '#0891b2', + axis: { y: { grid: true, gridStroke: '#f1f5f9' } }, + tooltip: { channel: 'y' as const }, + } + + const pieConfig = { + data: channelData, + angleField: 'value', + colorField: 'type', + height: 260, + radius: 0.85, + innerRadius: 0.55, + legend: { color: { position: 'right' as const } }, + label: false as const, + scale: { + color: { + range: ['#2563eb', '#0891b2', '#16a34a', '#d97706', '#7c3aed', '#94a3b8'], + }, + }, + } + return ( -
-
-

数据统计

- setTimeRange(v as 'today' | 'week' | 'month')} - options={[ - { value: 'today', label: '今日' }, - { value: 'week', label: '本周' }, - { value: 'month', label: '本月' }, - ]} - /> -
+
+ {/* 顶栏 56px — 与其它页面对齐 */} +
+
+

数据统计

+
+
+
+ {rangeOptions.map(opt => { + const active = timeRange === opt.value + return ( + + ) + })} +
+ +
+
- {loading && !kpis ?
: <> - - {kpiData.map((kpi, i) => ( - - -
- {kpi.label} - {kpi.icon} +
+ {loading && !kpis ? ( +
+ ) : ( +
+ {/* KPI */} +
+ {kpiCards.map(kpi => ( +
+ {kpi.delta && ( +
+ {kpi.up ? : } + {kpi.delta} +
+ )} +
+
+ {kpi.icon} +
+ {kpi.label} +
+
+ + {loading ? '—' : kpi.value} + + {kpi.suffix && ( + {kpi.suffix} + )} +
+

{kpi.hint}

-
{kpi.value}
-
基于已记录会话实时计算
- - - ))} - + ))} +
- - - - - - - - - - - - - - - - - - - - - - - } + {/* 图表 2×2 */} +
+
+
+

会话量趋势

+

{trendSubtitle[timeRange]}

+
+ {sessionTrendData.length === 0 ? ( +
暂无数据
+ ) : ( + + )} +
+ +
+
+

响应时长分布

+

首次响应耗时区间

+
+ {responseDistribution.every(d => !d.count) ? ( +
暂无数据
+ ) : ( + + )} +
+ +
+
+

渠道来源占比

+

各渠道会话量分布

+
+ {channelData.length === 0 ? ( +
暂无数据
+ ) : ( +
+ + {channelTotal > 0 && ( +
+ {channelTotal} + 总会话 +
+ )} +
+ )} +
+ +
+
+

客服绩效排行

+

按接待会话量排序

+
+ {agentPerformance.length === 0 ? ( +
暂无数据
+ ) : ( +
+ {agentPerformance.slice(0, 8).map((agent, idx) => { + const pct = Math.round((agent.conversations / maxConversations) * 100) + const rankColor = idx < 3 ? '#2563eb' : '#94a3b8' + return ( +
+ + {idx + 1} + + + {agent.name || '—'} + +
+
0 ? 4 : 0)}%`, opacity: 1 - idx * 0.08 }} + /> +
+ + {agent.conversations} + +
+ ) + })} +
+ )} +
+
+ + {/* 客服效率明细表 */} +
+
+

客服效率明细

+

接待量 · 平均响应 · 满意度

+
+
+ + + + + + + + + + + + {agentPerformance.length === 0 ? ( + + + + ) : ( + agentPerformance.map((agent, idx) => { + const pal = ['#dbeafe', '#ecfeff', '#f0fdf4', '#fffbeb', '#f3e8ff'][idx % 5] + const col = ['#2563eb', '#0891b2', '#16a34a', '#d97706', '#7c3aed'][idx % 5] + return ( + + + + + + + + ) + }) + )} + +
排名客服名称处理量平均响应时长满意度
+ 暂无客服绩效数据 +
+ + {idx + 1} + + +
+
+ {(agent.name || '?').slice(0, 1)} +
+ {agent.name || '—'} +
+
+ {agent.conversations} + + {Math.round(agent.avgResponse)}s + + {agent.satisfaction > 0 ? `${agent.satisfaction.toFixed(1)}/5` : '—'} +
+
+
+
+ )} +
) }