chore(history): import initial web product snapshot

This commit is contained in:
wmq
2026-07-25 00:30:00 +08:00
commit cf0eed46c3
131 changed files with 15693 additions and 0 deletions
+212
View File
@@ -0,0 +1,212 @@
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 { analyzeConversation } from '@/services/api/ai'
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'
export function InsightPanel({
customer,
conversation,
}: {
customer: Customer
conversation: Conversation
}) {
const queryClient = useQueryClient()
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 handleAnalyze = async () => {
setAnalyzing(true)
try {
const result = await analyzeConversation(conversation.id)
setAnalysis(result.summary)
toast.success('AI 分析完成', `意向评分 ${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 scrollbar-thin">
{/* 客户基本信息 */}
<div className="border-b border-border p-4">
<div className="mb-3 flex items-center gap-3">
<Avatar name={customer.name} size="lg" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{customer.name}</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) => (
<Badge key={t} variant="outline">{t}</Badge>
))}
</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="border-b border-border p-4">
<div className="flex items-center justify-between">
<h4 className="text-xs font-semibold text-muted-foreground">AI 分析摘要</h4>
<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-2 text-xs leading-relaxed text-muted-foreground">{analysis}</p>
)}
{/* AI 提取关键信息 */}
{customer.aiProfile.inquiryQuantity && (
<div className="mt-3 space-y-1 border-t border-border pt-3">
{Object.entries({
询价数量: customer.aiProfile.inquiryQuantity,
目标价格: customer.aiProfile.targetPrice,
MOQ: customer.aiProfile.moqRequirement,
样品: customer.aiProfile.sampleNeed,
定制: customer.aiProfile.customRequirement,
交付: customer.aiProfile.deliveryTime,
物流: customer.aiProfile.logistics,
付款: customer.aiProfile.payment,
顾虑: customer.aiProfile.concerns?.join('、'),
}).filter(([, v]) => v).map(([k, v]) => (
<div key={k} className="flex gap-2 text-xs">
<span className="shrink-0 text-muted-foreground">{k}</span>
<span className="text-foreground">{v as string}</span>
</div>
))}
</div>
)}
</div>
{/* 推荐动作 */}
<div className="p-4">
<h4 className="text-xs font-semibold text-muted-foreground">AI 推荐下一步</h4>
<div className="mt-2 flex flex-wrap gap-1.5">
{customer.aiProfile.nextActions.map((a) => (
<button
key={a}
onClick={() => toast.info(`建议操作:${a}`)}
className="rounded-full border border-primary-200 bg-primary-50 px-2.5 py-1 text-xs text-primary-700 hover:bg-primary-100"
>
{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>
)
}