Files
lingxi-web/src/components/customer/CustomerProfileRadar.tsx
T

182 lines
8.2 KiB
TypeScript

import { useId } from 'react'
import {
PolarAngleAxis,
PolarGrid,
PolarRadiusAxis,
Radar,
RadarChart,
ResponsiveContainer,
Tooltip,
} from 'recharts'
import { Sparkles, TrendingUp } from 'lucide-react'
import type { Customer } from '@/types'
import { cn } from '@/utils/cn'
function qualitativeScore(value?: string, fallback = 50) {
const text = String(value || '').toLowerCase()
if (!text) return fallback
if (/高|强|紧急|决策|老板|owner|director|high|urgent/.test(text)) return 86
if (/中|一般|参与|medium|normal/.test(text)) return 62
if (/低|弱|未知|助理|low|unknown/.test(text)) return 36
return fallback
}
export function profileRadarData(customer: Customer) {
const profile = customer.aiProfile || {}
const extracted = [
profile.companyName,
profile.industry,
profile.businessModel,
profile.inquiryQuantity,
profile.targetPrice,
profile.budget,
profile.purchaseTimeline,
profile.payment,
].filter(Boolean).length
const fitScore = Math.min(96, 42 + extracted * 6 + (profile.keyNeeds?.length || 0) * 4)
return [
{ dimension: '购买意向', score: Math.max(0, Math.min(100, Number(customer.intentScore || 0))), hint: '基于询盘强度与购买信号' },
{ dimension: '成交可能', score: Number(profile.dealProbability || 0), hint: '根据当前客户资料和会话信号估算' },
{ dimension: '决策能力', score: qualitativeScore(profile.decisionPower), hint: profile.decisionPower || '尚待确认采购决策权' },
{ dimension: '需求紧迫', score: qualitativeScore(profile.urgency), hint: profile.urgency || '尚待确认采购时间表' },
{ dimension: '客户匹配', score: fitScore, hint: '画像字段、需求与产品匹配度' },
{ dimension: '关系健康', score: Math.max(0, 100 - Number(profile.churnProbability || 0)), hint: '流失风险的反向健康度' },
]
}
export function CustomerProfileRadar({
customer,
compact = false,
className,
}: {
customer: Customer
compact?: boolean
className?: string
}) {
const data = profileRadarData(customer)
const average = Math.round(data.reduce((sum, item) => sum + item.score, 0) / data.length)
const chartId = useId().replace(/:/g, '')
const gradientId = `radar-fill-${chartId}`
const glowId = `radar-glow-${chartId}`
const level = average >= 75 ? '高潜客户' : average >= 55 ? '重点培育' : '持续观察'
const levelTone = average >= 75
? 'text-blue-700 bg-blue-50 border-blue-100'
: average >= 55
? 'text-violet-700 bg-violet-50 border-violet-100'
: 'text-amber-700 bg-amber-50 border-amber-100'
return (
<div className={cn(
'group relative isolate overflow-hidden rounded-2xl border border-blue-100/90 bg-white shadow-[0_18px_55px_-32px_rgba(37,99,235,.32)]',
className,
)}>
<div className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-40 bg-[radial-gradient(circle_at_75%_0%,rgba(96,165,250,.16),transparent_58%)]" />
<div className="flex items-start justify-between gap-3 px-5 pt-5">
<div>
<div className="flex items-center gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-violet-600 text-white shadow-[0_8px_20px_-8px_rgba(37,99,235,.7)]">
<Sparkles className="h-4 w-4" />
</span>
<div>
<p className="text-sm font-semibold tracking-tight text-slate-950">客户价值雷达</p>
<p className="mt-0.5 text-[11px] text-slate-500">基于当前客户资料和会话信号</p>
</div>
</div>
<span className={cn('mt-3 inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-[11px] font-semibold', levelTone)}>
<TrendingUp className="h-3 w-3" />
{level}
</span>
</div>
<div
className="relative flex h-[70px] w-[70px] shrink-0 items-center justify-center rounded-full p-[5px] shadow-[0_10px_30px_-16px_rgba(37,99,235,.7)]"
style={{ background: `conic-gradient(#2563eb ${average * 3.6}deg, #dbeafe 0deg)` }}
aria-label={`综合指数 ${average} 分`}
>
<div className="flex h-full w-full flex-col items-center justify-center rounded-full border border-white bg-white shadow-inner">
<p className="text-[9px] font-semibold tracking-wider text-blue-600">综合指数</p>
<p className="text-2xl font-bold leading-6 tracking-tight text-slate-950">{average}</p>
</div>
</div>
</div>
<div className={cn(
'customer-radar-stage relative mx-3 mt-1 overflow-hidden rounded-2xl',
'bg-[radial-gradient(circle_at_center,rgba(239,246,255,.96)_0%,rgba(255,255,255,.45)_62%,transparent_72%)]',
compact ? 'h-[240px]' : 'h-[310px]',
)}>
<div className="pointer-events-none absolute left-1/2 top-1/2 h-44 w-44 -translate-x-1/2 -translate-y-1/2 rounded-full border border-blue-100/80 shadow-[0_0_60px_rgba(37,99,235,.10)]" />
<ResponsiveContainer width="100%" height="100%">
<RadarChart data={data} cx="50%" cy="52%" outerRadius={compact ? '66%' : '72%'}>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#60a5fa" stopOpacity="0.58" />
<stop offset="55%" stopColor="#3b82f6" stopOpacity="0.34" />
<stop offset="100%" stopColor="#8b5cf6" stopOpacity="0.22" />
</linearGradient>
<filter id={glowId} x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="4" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<PolarGrid stroke="#dbeafe" strokeOpacity={0.95} gridType="polygon" />
<PolarAngleAxis dataKey="dimension" tick={{ fill: '#334155', fontSize: compact ? 10 : 12, fontWeight: 650 }} />
<PolarRadiusAxis angle={90} domain={[0, 100]} tick={false} axisLine={false} />
<Tooltip
formatter={(value: number) => [`${value} / 100`, '画像指数']}
labelFormatter={(label) => `${label} · 参考值`}
contentStyle={{
borderRadius: 14,
border: '1px solid #dbeafe',
boxShadow: '0 18px 40px rgba(37,99,235,.14)',
fontSize: 12,
}}
/>
<Radar
name="客户画像"
dataKey="score"
isAnimationActive={false}
stroke="#60a5fa"
strokeWidth={8}
strokeOpacity={0.16}
fill="none"
dot={false}
filter={`url(#${glowId})`}
/>
<Radar
name="客户画像"
dataKey="score"
isAnimationActive={false}
stroke="#2563eb"
strokeWidth={3}
fill={`url(#${gradientId})`}
fillOpacity={1}
dot={{ r: compact ? 3.5 : 4.5, fill: '#2563eb', strokeWidth: 3, stroke: '#fff' }}
/>
</RadarChart>
</ResponsiveContainer>
</div>
{!compact && (
<div className="grid grid-cols-2 gap-2 border-t border-slate-100 bg-slate-50/40 px-4 py-4 sm:grid-cols-3">
{data.map((item) => (
<div key={item.dimension} className="min-w-0 rounded-xl border border-slate-100 bg-white px-3 py-2.5 shadow-sm">
<div className="flex items-end justify-between gap-2">
<span className="text-[11px] font-medium text-slate-500">{item.dimension}</span>
<span className="text-base font-bold leading-none text-slate-900">{item.score}</span>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-slate-100">
<div
className="h-full rounded-full bg-gradient-to-r from-blue-400 via-blue-600 to-violet-500 shadow-[0_0_8px_rgba(37,99,235,.28)]"
style={{ width: `${item.score}%` }}
/>
</div>
</div>
))}
</div>
)}
</div>
)
}