266 lines
11 KiB
TypeScript
266 lines
11 KiB
TypeScript
import { useState } from 'react'
|
||
import { Link } from 'react-router-dom'
|
||
import {
|
||
Building2,
|
||
Clock,
|
||
ExternalLink,
|
||
Mail,
|
||
MapPin,
|
||
Phone,
|
||
Tags,
|
||
Target,
|
||
Zap,
|
||
AlertTriangle,
|
||
} from 'lucide-react'
|
||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||
import { updateCustomer } from '@/services/api/customers'
|
||
import { createTask } from '@/services/api/crm'
|
||
import { analyzeConversation } from '@/services/api/ai'
|
||
import { useAuthStore } from '@/app/store/auth'
|
||
import type { Customer, Conversation } from '@/types'
|
||
import { Avatar } from '@/components/ui/avatar'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Badge } from '@/components/ui/badge'
|
||
import { Select } from '@/components/ui/select'
|
||
import { Card, CardHeader } from '@/components/ui/card'
|
||
import { Switch } from '@/components/ui/switch'
|
||
import { Spinner } from '@/components/ui/feedback'
|
||
import { ChannelIcon } from '@/components/common/ChannelIcon'
|
||
import { CountryFlag } from '@/components/common/CountryFlag'
|
||
import { IntentScoreBadge, CustomerStageBadge, SentimentBadge, RiskLevelBadge } from '@/components/common/StatusBadges'
|
||
import { CUSTOMER_STAGES, NEXT_ACTIONS, countryMeta, INTENT_LEVELS } from '@/config/constants'
|
||
import { toast, errorMessage } from '@/app/store/toast'
|
||
import { cn } from '@/utils/cn'
|
||
import { CustomerProfileRadar } from '@/components/customer/CustomerProfileRadar'
|
||
import { CustomerSemanticTag } from '@/components/customer/CustomerSemanticTag'
|
||
|
||
export function InsightPanel({
|
||
customer,
|
||
conversation,
|
||
}: {
|
||
customer: Customer
|
||
conversation: Conversation
|
||
}) {
|
||
const queryClient = useQueryClient()
|
||
const user = useAuthStore((state) => state.user)
|
||
const [analyzing, setAnalyzing] = useState(false)
|
||
const [analysis, setAnalysis] = useState(customer.aiProfile.summary)
|
||
const c = countryMeta(customer.country)
|
||
const localTime = new Date(
|
||
new Date().getTime() + new Date().getTimezoneOffset() * 60000 + c.tzOffset * 3600000,
|
||
).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||
|
||
const updateMut = useMutation({
|
||
mutationFn: (data: Partial<Customer>) => updateCustomer(customer.id, data),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['customers'] })
|
||
queryClient.invalidateQueries({ queryKey: ['conversations'] })
|
||
toast.success('客户信息已更新')
|
||
},
|
||
onError: (e) => toast.error(errorMessage(e)),
|
||
})
|
||
const taskMut = useMutation({
|
||
mutationFn: (title: string) => createTask({
|
||
title,
|
||
type: 'revisit',
|
||
priority: customer.riskLevel === 'urgent' || customer.riskLevel === 'high' ? 'high' : 'medium',
|
||
status: 'pending',
|
||
customerId: customer.id,
|
||
customerName: customer.name,
|
||
conversationId: conversation.id,
|
||
ownerId: user?.id || '',
|
||
dueAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
||
note: `由客户洞察建议创建:${title}`,
|
||
}),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['tasks'] })
|
||
toast.success('已创建跟进任务', '默认明天到期')
|
||
},
|
||
onError: (error) => toast.error(errorMessage(error)),
|
||
})
|
||
|
||
const handleAnalyze = async () => {
|
||
setAnalyzing(true)
|
||
try {
|
||
const result = await analyzeConversation(conversation.id)
|
||
setAnalysis(result.summary)
|
||
toast.success('会话分析完成', `意向评分 ${result.intentScore} · ${INTENT_LEVELS.find(l => l.value === result.intentLevel)?.label}`)
|
||
} catch {
|
||
toast.error('分析失败,请稍后重试')
|
||
} finally { setAnalyzing(false) }
|
||
}
|
||
|
||
return (
|
||
<div className="flex h-full flex-col overflow-y-auto bg-slate-50/40 scrollbar-thin">
|
||
{/* 客户基本信息 */}
|
||
<div className="border-b border-slate-100 bg-white p-5">
|
||
<div className="mb-3 flex items-center gap-3">
|
||
<Avatar name={customer.name} src={customer.avatarUrl} size="lg" />
|
||
<div className="min-w-0 flex-1">
|
||
<p className="truncate text-sm font-semibold">{customer.name}</p>
|
||
{customer.telegramUsername && (
|
||
<p className="truncate text-xs text-primary">@{customer.telegramUsername}</p>
|
||
)}
|
||
{customer.company && (
|
||
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
||
<Building2 className="h-3 w-3" />
|
||
{customer.company}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-1.5 text-sm">
|
||
<div className="flex items-center gap-2">
|
||
<MapPin className="h-3.5 w-3.5 text-muted-foreground" />
|
||
<CountryFlag code={customer.country} />
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
|
||
<span className="text-muted-foreground">当地时间:{localTime}</span>
|
||
</div>
|
||
{customer.email && (
|
||
<div className="flex items-center gap-2">
|
||
<Mail className="h-3.5 w-3.5 text-muted-foreground" />
|
||
<span className="truncate text-xs">{customer.email}</span>
|
||
</div>
|
||
)}
|
||
{customer.phone && (
|
||
<div className="flex items-center gap-2">
|
||
<Phone className="h-3.5 w-3.5 text-muted-foreground" />
|
||
<span className="text-xs">{customer.phone}</span>
|
||
</div>
|
||
)}
|
||
<div className="flex items-center gap-2">
|
||
<ChannelIcon channel={customer.channel} size="xs" withBg={false} />
|
||
<span className="text-xs text-muted-foreground">
|
||
{customer.channel} · {customer.sourcePath ?? '—'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||
{customer.products?.map((p) => (
|
||
<Badge key={p} variant="primary">{p}</Badge>
|
||
))}
|
||
{customer.tags?.map((t) => (
|
||
<CustomerSemanticTag key={t} tag={t} />
|
||
))}
|
||
</div>
|
||
|
||
<div className="mt-3 grid grid-cols-2 gap-y-1.5 text-xs">
|
||
<div className="flex items-center gap-1">
|
||
<Target className="h-3 w-3 text-muted-foreground" />
|
||
<span>意向评分</span>
|
||
</div>
|
||
<IntentScoreBadge score={customer.intentScore} withBar />
|
||
<div className="flex items-center gap-1">
|
||
<Zap className="h-3 w-3 text-muted-foreground" />
|
||
<span>成交阶段</span>
|
||
</div>
|
||
<Select
|
||
value={customer.stage}
|
||
onChange={(e) => updateMut.mutate({ stage: e.target.value as any })}
|
||
options={CUSTOMER_STAGES.map((s) => ({ value: s.value, label: s.label }))}
|
||
className="h-7 text-xs"
|
||
/>
|
||
<div className="flex items-center gap-1">😊 情绪</div>
|
||
<SentimentBadge sentiment={customer.sentiment} />
|
||
<div className="flex items-center gap-1">
|
||
<AlertTriangle className="h-3 w-3 text-muted-foreground" />
|
||
<span>风险</span>
|
||
</div>
|
||
<RiskLevelBadge level={customer.riskLevel} />
|
||
</div>
|
||
</div>
|
||
|
||
{/* AI 分析 */}
|
||
<div className="space-y-4 border-b border-slate-100 p-3">
|
||
<CustomerProfileRadar customer={customer} compact />
|
||
<div className="rounded-2xl border border-slate-200/80 bg-white p-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm font-semibold tracking-tight text-slate-900">会话摘要</p>
|
||
<p className="text-[11px] text-slate-400">根据已同步的会话内容生成</p>
|
||
</div>
|
||
<Button variant="ghost" size="sm" onClick={handleAnalyze} disabled={analyzing}>
|
||
{analyzing ? <Spinner className="h-3 w-3" /> : '重新分析'}
|
||
</Button>
|
||
</div>
|
||
{analyzing ? (
|
||
<div className="mt-3 flex items-center justify-center py-6">
|
||
<Spinner />
|
||
<span className="ml-2 text-sm text-muted-foreground">正在分析会话上下文…</span>
|
||
</div>
|
||
) : (
|
||
<p className="mt-3 text-xs leading-6 text-slate-600">{analysis || '收到更多客户消息后将补充客户画像。'}</p>
|
||
)}
|
||
{/* AI 提取关键信息 */}
|
||
{Object.values(customer.aiProfile || {}).some(Boolean) && (
|
||
<div className="mt-4 grid grid-cols-2 gap-2 border-t border-slate-100 pt-4">
|
||
{Object.entries({
|
||
行业: customer.aiProfile.industry,
|
||
采购角色: customer.aiProfile.purchasingRole,
|
||
询价数量: customer.aiProfile.inquiryQuantity,
|
||
目标价格: customer.aiProfile.targetPrice,
|
||
预算: customer.aiProfile.budget,
|
||
MOQ: customer.aiProfile.moqRequirement,
|
||
样品: customer.aiProfile.sampleNeed,
|
||
交付: customer.aiProfile.deliveryTime,
|
||
}).filter(([, v]) => v).map(([k, v]) => (
|
||
<div key={k} className="min-w-0 rounded-xl bg-slate-50 px-3 py-2">
|
||
<p className="text-[10px] font-medium text-slate-400">{k}</p>
|
||
<p className="mt-0.5 truncate text-xs font-medium text-slate-700">{v as string}</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||
{[
|
||
['成交概率', customer.aiProfile.dealProbability],
|
||
['流失概率', customer.aiProfile.churnProbability],
|
||
['分析可信度', customer.aiProfile.confidenceScore],
|
||
].map(([label, value]) => (
|
||
<div key={String(label)} className="rounded-xl bg-slate-50 px-2 py-2 text-center">
|
||
<p className="text-[10px] text-muted-foreground">{label}</p>
|
||
<p className="text-sm font-semibold">{Number(value || 0)}%</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{customer.aiProfile.evidence?.length ? (
|
||
<div className="mt-3 border-t border-border pt-3">
|
||
<p className="text-[11px] font-medium text-muted-foreground">判断依据</p>
|
||
<ul className="mt-1 space-y-1 text-xs text-muted-foreground">
|
||
{customer.aiProfile.evidence.map((item) => <li key={item}>• {item}</li>)}
|
||
</ul>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 推荐动作 */}
|
||
<div className="p-4">
|
||
<h4 className="text-xs font-semibold text-slate-500">下一步建议</h4>
|
||
<div className="mt-2 space-y-2">
|
||
{customer.aiProfile.nextActions.map((a) => (
|
||
<button
|
||
key={a}
|
||
onClick={() => taskMut.mutate(a)}
|
||
disabled={taskMut.isPending}
|
||
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-start text-xs text-slate-700 hover:border-primary-200 hover:bg-primary-50"
|
||
>
|
||
{a}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<Link
|
||
to={`/customers/${customer.id}`}
|
||
className="mt-3 inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
|
||
>
|
||
查看完整客户档案 <ExternalLink className="h-3 w-3" />
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|