实现数据统计页并对齐顶栏布局
按效果图重做 KPI 与图表区,固定 56px 顶栏时间筛选,内容区铺满宽度。
This commit is contained in:
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user