实现数据统计页并对齐顶栏布局

按效果图重做 KPI 与图表区,固定 56px 顶栏时间筛选,内容区铺满宽度。
This commit is contained in:
yml2213
2026-07-15 13:30:31 +08:00
parent 4c298fc38d
commit 5257165c78
+389 -83
View File
@@ -1,16 +1,38 @@
import { useEffect, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Card, Segmented, Row, Col, Spin } from 'antd' import { Spin, message } from 'antd'
import { ClockCircleOutlined, SmileOutlined, CheckCircleOutlined, MessageOutlined } from '@ant-design/icons' import {
import { Column, Line, Pie, Bar } from '@ant-design/charts' ClockCircleOutlined, SmileOutlined, CheckCircleOutlined, MessageOutlined,
import { getAgentPerformance, getChannelDistribution, getKPIs, getResponseDistribution, getSessionTrend, type StatisticsKpis } from '@/services/api' 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<TimeRange, string> = {
today: '今日会话量',
week: '近7天会话量变化',
month: '近6个月会话量变化',
}
const Statistics = () => { const Statistics = () => {
const [timeRange, setTimeRange] = useState<'today' | 'week' | 'month'>('week') const [timeRange, setTimeRange] = useState<TimeRange>('week')
const [kpis, setKpis] = useState<StatisticsKpis | null>(null) const [kpis, setKpis] = useState<StatisticsKpis | null>(null)
const [sessionTrendData, setSessionTrendData] = useState<{ date: string; count: number }[]>([]) const [sessionTrendData, setSessionTrendData] = useState<{ date: string; count: number }[]>([])
const [responseDistribution, setResponseDistribution] = useState<{ range: string; count: number }[]>([]) const [responseDistribution, setResponseDistribution] = useState<{ range: string; count: number }[]>([])
const [channelData, setChannelData] = useState<{ type: string; value: 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) const [loading, setLoading] = useState(true)
useEffect(() => { useEffect(() => {
@@ -19,96 +41,380 @@ const Statistics = () => {
try { try {
const period = timeRange === 'week' ? 'day' : timeRange const period = timeRange === 'week' ? 'day' : timeRange
const [kpiRes, trendRes, distributionRes, channelRes, performanceRes] = await Promise.all([ const [kpiRes, trendRes, distributionRes, channelRes, performanceRes] = await Promise.all([
getKPIs(), getSessionTrend(period), getResponseDistribution(), getChannelDistribution(), getAgentPerformance(), getKPIs(),
getSessionTrend(period),
getResponseDistribution(),
getChannelDistribution(),
getAgentPerformance(),
]) ])
setKpis(kpiRes.data) setKpis(kpiRes.data)
setSessionTrendData(trendRes.data) setSessionTrendData(Array.isArray(trendRes.data) ? trendRes.data : [])
setResponseDistribution(distributionRes.data) setResponseDistribution(Array.isArray(distributionRes.data) ? distributionRes.data : [])
setChannelData(channelRes.data) setChannelData(Array.isArray(channelRes.data) ? channelRes.data : [])
setAgentPerformance(performanceRes.data.map(item => ({ setAgentPerformance(
name: item.name, (Array.isArray(performanceRes.data) ? performanceRes.data : []).map(item => ({
conversations: item.conversations, name: item.name,
avgResponse: item.avg_response, conversations: item.conversations,
satisfaction: item.satisfaction, avgResponse: item.avg_response,
}))) satisfaction: item.satisfaction,
})),
)
} catch {
setKpis(null)
setSessionTrendData([])
setResponseDistribution([])
setChannelData([])
setAgentPerformance([])
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
load().catch(() => { load()
setKpis(null)
setSessionTrendData([])
setResponseDistribution([])
setChannelData([])
setAgentPerformance([])
})
}, [timeRange]) }, [timeRange])
const kpiData = [ const satPercent = useMemo(() => {
{ label: '总会话量', value: String(kpis?.total_sessions ?? 0), icon: <MessageOutlined />, color: '#2563eb' }, const avg = kpis?.satisfaction_avg ?? 0
{ label: '平均响应时长', value: `${Math.round(kpis?.avg_response_time ?? 0)}s`, icon: <ClockCircleOutlined />, color: '#16a34a' }, return (avg / 5) * 100
{ label: '客户满意度', value: `${(kpis?.satisfaction_avg ?? 0).toFixed(1)}/5`, icon: <SmileOutlined />, color: '#d97706' }, }, [kpis])
{ label: '首次解决率', value: `${(kpis?.first_resolve_rate ?? 0).toFixed(1)}%`, icon: <CheckCircleOutlined />, color: '#0891b2' },
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: <MessageOutlined />,
iconBg: '#eff6ff',
iconColor: '#2563eb',
delta: null as string | null,
up: true,
},
{
label: '平均响应时长',
value: String(Math.round(kpis?.avg_response_time ?? 0)),
suffix: '秒',
hint: '响应越短越优',
icon: <ClockCircleOutlined />,
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: <SmileOutlined />,
iconBg: '#f0fdf4',
iconColor: '#16a34a',
delta: null,
up: true,
},
{
label: '首次解决率',
value: (kpis?.first_resolve_rate ?? 0).toFixed(1),
suffix: '%',
hint: '24h 内未再打开',
icon: <CheckCircleOutlined />,
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 ( return (
<div className="h-full overflow-auto p-6"> <div className="h-full flex flex-col min-h-0 overflow-hidden bg-neutral-50">
<div className="flex items-center justify-between mb-6"> {/* 顶栏 56px — 与其它页面对齐 */}
<h2 className="text-lg font-semibold text-neutral-800"></h2> <header
<Segmented className="shrink-0 px-6 flex items-center justify-between gap-3 border-b border-neutral-200 bg-white"
value={timeRange} style={{ height: 'var(--header-height)' }}
onChange={v => setTimeRange(v as 'today' | 'week' | 'month')} >
options={[ <div className="min-w-0">
{ value: 'today', label: '今日' }, <h1 className="text-base font-semibold text-neutral-900 m-0 truncate"></h1>
{ value: 'week', label: '本周' }, </div>
{ value: 'month', label: '本月' }, <div className="flex items-center gap-2 shrink-0">
]} <div className="flex items-center gap-0.5 p-0.5 rounded-lg bg-neutral-100">
/> {rangeOptions.map(opt => {
</div> const active = timeRange === opt.value
return (
<button
key={opt.value}
type="button"
onClick={() => setTimeRange(opt.value)}
className={`h-7 px-3 rounded-md text-sm border-0 cursor-pointer transition-colors ${
active
? 'bg-[#2563eb] text-white font-medium shadow-sm'
: 'bg-transparent text-neutral-500 hover:text-neutral-700'
}`}
>
{opt.label}
</button>
)
})}
</div>
<button
type="button"
className="h-8 px-3 rounded-lg text-sm text-neutral-600 border border-neutral-200 bg-white hover:bg-neutral-50 flex items-center gap-1.5 cursor-pointer"
onClick={() => message.info('导出报告功能后续版本提供')}
>
<DownloadOutlined />
</button>
</div>
</header>
{loading && !kpis ? <div className="h-64 flex items-center justify-center"><Spin size="large" /></div> : <> <div className="flex-1 min-h-0 overflow-auto px-6 py-5">
<Row gutter={[16, 16]} className="mb-6"> {loading && !kpis ? (
{kpiData.map((kpi, i) => ( <div className="h-64 flex items-center justify-center"><Spin size="large" /></div>
<Col key={i} xs={24} sm={12} lg={6}> ) : (
<Card className="!rounded-lg" bordered={false} loading={loading}> <div className="flex flex-col gap-5 w-full">
<div className="flex items-center justify-between mb-3"> {/* KPI */}
<span className="text-sm text-neutral-400">{kpi.label}</span> <section className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
<span className="text-lg" style={{ color: kpi.color }}>{kpi.icon}</span> {kpiCards.map(kpi => (
<div
key={kpi.label}
className="relative bg-white rounded-xl border border-neutral-200 shadow-sm p-5"
>
{kpi.delta && (
<div className="absolute top-4 right-4 flex items-center gap-0.5 text-xs font-medium text-green-600 opacity-80">
{kpi.up ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
{kpi.delta}
</div>
)}
<div className="flex items-center gap-3 mb-3">
<div
className="w-9 h-9 rounded-lg flex items-center justify-center text-base shrink-0"
style={{ backgroundColor: kpi.iconBg, color: kpi.iconColor }}
>
{kpi.icon}
</div>
<span className="text-sm text-neutral-500 whitespace-nowrap">{kpi.label}</span>
</div>
<div className="flex items-baseline gap-1">
<span className="text-3xl font-bold text-neutral-900 leading-none tabular-nums">
{loading ? '—' : kpi.value}
</span>
{kpi.suffix && (
<span className="text-base font-normal text-neutral-400">{kpi.suffix}</span>
)}
</div>
<p className="mt-1.5 mb-0 text-xs text-neutral-400">{kpi.hint}</p>
</div> </div>
<div className="text-2xl font-bold text-neutral-800 mb-1">{kpi.value}</div> ))}
<div className="text-xs text-neutral-400"></div> </section>
</Card>
</Col>
))}
</Row>
<Row gutter={[16, 16]}> {/* 图表 2×2 */}
<Col xs={24} lg={12}> <section className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card title="会话量趋势" className="!rounded-lg" bordered={false}> <div className="bg-white rounded-xl border border-neutral-200 shadow-sm p-5">
<Line data={sessionTrendData} xField="date" yField="count" smooth height={260} color="#2563eb" point={{ size: 3 }} tooltip={{ channel: 'y' }} axis={{ y: { grid: true, gridStroke: '#f1f5f9' } }} /> <div className="mb-4">
</Card> <h3 className="m-0 text-[15px] font-semibold text-neutral-900"></h3>
</Col> <p className="m-0 mt-0.5 text-xs text-neutral-400">{trendSubtitle[timeRange]}</p>
<Col xs={24} lg={12}> </div>
<Card title="响应时长分布" className="!rounded-lg" bordered={false}> {sessionTrendData.length === 0 ? (
<Column data={responseDistribution} xField="range" yField="count" height={260} color="#0891b2" tooltip={{ channel: 'y' }} axis={{ y: { grid: true, gridStroke: '#f1f5f9' } }} /> <div className="h-[260px] flex items-center justify-center text-sm text-neutral-400"></div>
</Card> ) : (
</Col> <Line {...lineConfig} />
<Col xs={24} lg={12}> )}
<Card title="渠道来源占比" className="!rounded-lg" bordered={false}> </div>
<Pie data={channelData} angleField="value" colorField="type" height={260} radius={0.8} innerRadius={0.5} label={{ text: 'type', position: 'outside' }} legend={{ color: { position: 'bottom' } }} />
</Card> <div className="bg-white rounded-xl border border-neutral-200 shadow-sm p-5">
</Col> <div className="mb-4">
<Col xs={24} lg={12}> <h3 className="m-0 text-[15px] font-semibold text-neutral-900"></h3>
<Card title="客服绩效排行" className="!rounded-lg" bordered={false}> <p className="m-0 mt-0.5 text-xs text-neutral-400"></p>
<Bar data={agentPerformance} xField="conversations" yField="name" height={260} color="#2563eb" tooltip={{ items: [ </div>
{ channel: 'conversations', name: '接待量' }, {responseDistribution.every(d => !d.count) ? (
{ channel: 'avgResponse', name: '平均响应(s)' }, <div className="h-[260px] flex items-center justify-center text-sm text-neutral-400"></div>
{ channel: 'satisfaction', name: '满意度' }, ) : (
]}} axis={{ x: { grid: true, gridStroke: '#f1f5f9' } }} /> <Column {...columnConfig} />
</Card> )}
</Col> </div>
</Row>
</>} <div className="bg-white rounded-xl border border-neutral-200 shadow-sm p-5">
<div className="mb-4">
<h3 className="m-0 text-[15px] font-semibold text-neutral-900"></h3>
<p className="m-0 mt-0.5 text-xs text-neutral-400"></p>
</div>
{channelData.length === 0 ? (
<div className="h-[260px] flex items-center justify-center text-sm text-neutral-400"></div>
) : (
<div className="relative">
<Pie {...pieConfig} />
{channelTotal > 0 && (
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center pr-16">
<span className="text-xl font-bold text-neutral-900 tabular-nums">{channelTotal}</span>
<span className="text-[10px] text-neutral-400"></span>
</div>
)}
</div>
)}
</div>
<div className="bg-white rounded-xl border border-neutral-200 shadow-sm p-5">
<div className="mb-4">
<h3 className="m-0 text-[15px] font-semibold text-neutral-900"></h3>
<p className="m-0 mt-0.5 text-xs text-neutral-400"></p>
</div>
{agentPerformance.length === 0 ? (
<div className="h-[260px] flex items-center justify-center text-sm text-neutral-400"></div>
) : (
<div className="flex flex-col gap-3 min-h-[240px]">
{agentPerformance.slice(0, 8).map((agent, idx) => {
const pct = Math.round((agent.conversations / maxConversations) * 100)
const rankColor = idx < 3 ? '#2563eb' : '#94a3b8'
return (
<div key={agent.name + idx} className="flex items-center gap-3">
<span
className="w-5 text-center text-xs font-bold shrink-0 tabular-nums"
style={{ color: rankColor }}
>
{idx + 1}
</span>
<span className="w-14 text-sm text-neutral-700 truncate shrink-0" title={agent.name}>
{agent.name || '—'}
</span>
<div className="flex-1 h-5 rounded bg-neutral-100 overflow-hidden">
<div
className="h-full rounded bg-[#2563eb] transition-all"
style={{ width: `${Math.max(pct, agent.conversations > 0 ? 4 : 0)}%`, opacity: 1 - idx * 0.08 }}
/>
</div>
<span className="w-8 text-right text-xs font-medium text-neutral-700 tabular-nums shrink-0">
{agent.conversations}
</span>
</div>
)
})}
</div>
)}
</div>
</section>
{/* 客服效率明细表 */}
<section className="bg-white rounded-xl border border-neutral-200 shadow-sm overflow-hidden">
<div className="px-5 pt-5 pb-0">
<h3 className="m-0 text-[15px] font-semibold text-neutral-900"></h3>
<p className="m-0 mt-0.5 text-xs text-neutral-400"> · · </p>
</div>
<div className="overflow-x-auto px-2 pb-4 pt-3">
<table className="w-full min-w-[640px] border-collapse">
<thead>
<tr className="text-[11px] font-medium text-neutral-400">
<th className="text-left px-4 py-2 border-b border-neutral-200 w-16"></th>
<th className="text-left px-4 py-2 border-b border-neutral-200"></th>
<th className="text-right px-4 py-2 border-b border-neutral-200"></th>
<th className="text-right px-4 py-2 border-b border-neutral-200"></th>
<th className="text-right px-4 py-2 border-b border-neutral-200"></th>
</tr>
</thead>
<tbody>
{agentPerformance.length === 0 ? (
<tr>
<td colSpan={5} className="text-center text-sm text-neutral-400 py-10">
</td>
</tr>
) : (
agentPerformance.map((agent, idx) => {
const pal = ['#dbeafe', '#ecfeff', '#f0fdf4', '#fffbeb', '#f3e8ff'][idx % 5]
const col = ['#2563eb', '#0891b2', '#16a34a', '#d97706', '#7c3aed'][idx % 5]
return (
<tr key={agent.name + idx} className="hover:bg-neutral-50">
<td className="px-4 py-3 border-b border-neutral-100">
<span
className="inline-flex items-center justify-center w-[22px] h-[22px] rounded-full text-[11px] font-bold"
style={{
backgroundColor: idx < 3 ? '#dbeafe' : '#f1f5f9',
color: idx < 3 ? '#2563eb' : '#94a3b8',
}}
>
{idx + 1}
</span>
</td>
<td className="px-4 py-3 border-b border-neutral-100">
<div className="flex items-center gap-2">
<div
className="w-7 h-7 rounded-full flex items-center justify-center text-xs font-semibold shrink-0"
style={{ backgroundColor: pal, color: col }}
>
{(agent.name || '?').slice(0, 1)}
</div>
<span className="text-sm text-neutral-800">{agent.name || '—'}</span>
</div>
</td>
<td className="px-4 py-3 border-b border-neutral-100 text-right text-sm font-medium text-neutral-800 tabular-nums">
{agent.conversations}
</td>
<td className="px-4 py-3 border-b border-neutral-100 text-right text-sm text-neutral-700 tabular-nums">
{Math.round(agent.avgResponse)}s
</td>
<td className="px-4 py-3 border-b border-neutral-100 text-right text-sm text-neutral-700 tabular-nums">
{agent.satisfaction > 0 ? `${agent.satisfaction.toFixed(1)}/5` : '—'}
</td>
</tr>
)
})
)}
</tbody>
</table>
</div>
</section>
</div>
)}
</div>
</div> </div>
) )
} }