Files
lingxi-web/src/components/inbox/InsightPanel.tsx
T

266 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}