Files
lingxi-web/src/pages/customers/CustomersPage.tsx
T

403 lines
19 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, useMemo } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Download,
GitMerge,
LayoutGrid,
List,
MoreHorizontal,
Plus,
Search,
TableProperties,
Tag,
Trash2,
Upload,
UserRoundPlus,
} from 'lucide-react'
import { getCustomers, deleteCustomer, importCustomers, exportCustomers, getDuplicateCustomers, mergeCustomers } from '@/services/api/customers'
import { useI18n } from '@/i18n'
import { useAuthStore } from '@/app/store/auth'
import { usePermission } from '@/hooks/usePermission'
import { toast, errorMessage } from '@/app/store/toast'
import { confirmDialog } from '@/app/store/confirm'
import { PageLoading, ErrorState, EmptyState } from '@/components/ui/feedback'
import { PageHeader } from '@/components/common/PageHeader'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox'
import { Badge } from '@/components/ui/badge'
import { Card } from '@/components/ui/card'
import { Avatar } from '@/components/ui/avatar'
import { Dropdown } from '@/components/ui/dropdown'
import { Table, THead, TBody, Tr, Th, Td } from '@/components/ui/table'
import { Dialog } from '@/components/ui/dialog'
import { Tabs } from '@/components/ui/tabs'
import { PermissionGate } from '@/components/common/PermissionGate'
import { CountryFlag } from '@/components/common/CountryFlag'
import { ChannelIcon } from '@/components/common/ChannelIcon'
import { IntentScoreBadge, CustomerStageBadge, SentimentBadge, RiskLevelBadge } from '@/components/common/StatusBadges'
import { CustomerFormDialog } from '@/components/customer/CustomerFormDialog'
import {
COUNTRIES,
CUSTOMER_STAGES,
CUSTOMER_TAGS,
CHANNEL_META,
INTENT_LEVELS,
} from '@/config/constants'
import { cn } from '@/utils/cn'
import type { Customer } from '@/types'
import type { ChannelType } from '@/types/messaging'
export function CustomersPage() {
const { t } = useI18n()
const navigate = useNavigate()
const queryClient = useQueryClient()
const { can } = usePermission()
const user = useAuthStore((s) => s.user)
const [searchParams, setSearchParams] = useSearchParams()
const [view, setView] = useState<'table' | 'card' | 'kanban'>('table')
const [keyword, setKeyword] = useState(searchParams.get('keyword') ?? '')
const [country, setCountry] = useState(searchParams.get('country') ?? '')
const [channel, setChannel] = useState(searchParams.get('channel') ?? '')
const [stage, setStage] = useState(searchParams.get('stage') ?? '')
const [intentLevel, setIntentLevel] = useState(searchParams.get('intentLevel') ?? '')
const [selected, setSelected] = useState<Set<string>>(new Set())
const [formDialog, setFormDialog] = useState(false)
const [editCustomer, setEditCustomer] = useState<Customer | null>(null)
const [importDialog, setImportDialog] = useState(false)
const [mergeDialog, setMergeDialog] = useState(false)
const [duplicateList, setDuplicateList] = useState<any[]>([])
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['customers', 'all'],
queryFn: () => getCustomers({ keyword, country, channel, stage, intentLevel }),
})
const updateMut = useMutation({
mutationFn: (p: { id: string; data: Partial<Customer> }) => import('@/services/api/customers').then((api) => api.updateCustomer(p.id, p.data)),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customers'] })
toast.success('已更新')
},
onError: (e) => toast.error(errorMessage(e)),
})
const batchAssign = async () => {
const owner = prompt('请输入负责人姓名(演示环境请输入 李婷/赵磊/周敏/郑楠):')
if (!owner) return
const team = (await import('@/services/api/org').then((api) => api.getTeam())).find((m) => m.name === owner)
if (!team) return toast.error('未找到该成员')
for (const id of selected) {
try { await updateMut.mutateAsync({ id, data: { ownerId: team.id } }) } catch {}
}
setSelected(new Set())
toast.success(`已分配 ${selected.size} 位客户给 ${owner}`)
}
const batchTag = async () => {
const tag = prompt('请输入要添加的标签:')
if (!tag) return
for (const id of selected) {
const c = data?.list.find((x: Customer) => x.id === id)
if (c) {
try { await updateMut.mutateAsync({ id, data: { tags: [...c.tags, tag] } }) } catch {}
}
}
setSelected(new Set())
toast.success(`已为 ${selected.size} 位客户添加标签「${tag}」`)
}
const handleExport = async () => {
const res = await exportCustomers()
const { downloadText } = await import('@/utils/misc')
downloadText(`客户列表_${new Date().toISOString().slice(0, 10)}.csv`, res.csv, 'text/csv')
toast.success('导出成功')
}
const handleImport = async () => {
const rows = [
{ name: 'Demo Import 1', country: 'US', email: 'demo1@example.com' },
{ name: 'Demo Import 2', country: 'PK', email: 'demo2@example.com' },
]
const res = await importCustomers(rows)
toast.success(`导入完成:${res.imported} 条成功`)
setImportDialog(false)
queryClient.invalidateQueries({ queryKey: ['customers'] })
}
const handleCheckDuplicates = async () => {
const dup = await getDuplicateCustomers()
setDuplicateList(dup)
setMergeDialog(true)
}
const handleMerge = async (keepId: string, mergeId: string) => {
await mergeCustomers(keepId, mergeId)
toast.success('客户合并完成')
queryClient.invalidateQueries({ queryKey: ['customers'] })
setMergeDialog(false)
}
const handleDelete = (c: Customer) => {
confirmDialog({
title: `删除客户「${c.name}」?`,
description: '删除后相关会话和任务将被一同移除,此操作不可恢复。',
danger: true,
onConfirm: async () => {
await deleteCustomer(c.id)
toast.success('已删除')
queryClient.invalidateQueries({ queryKey: ['customers'] })
},
})
}
const list = data?.list ?? []
const total = data?.total ?? 0
if (isLoading) return <PageLoading />
if (isError) return <ErrorState onRetry={() => refetch()} />
return (
<div className="p-4 md:p-6">
<PageHeader
title="客户管理"
description={`共 ${total} 个客户 · 统一管理与跟进`}
actions={
<>
<PermissionGate permission="customer:export">
<Button variant="outline" size="sm" onClick={handleExport}>
<Download className="h-4 w-4" />
导出
</Button>
<Button variant="outline" size="sm" onClick={() => setImportDialog(true)}>
<Upload className="h-4 w-4" />
导入
</Button>
<Button variant="outline" size="sm" onClick={handleCheckDuplicates}>
<GitMerge className="h-4 w-4" />
去重
</Button>
</PermissionGate>
<PermissionGate permission="customer:edit">
<Button size="sm" onClick={() => { setEditCustomer(null); setFormDialog(true) }}>
<Plus className="h-4 w-4" />
新建客户
</Button>
</PermissionGate>
</>
}
extra={
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-[200px] max-w-xs">
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
placeholder="搜索客户名称/公司/国家/产品"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-card pl-8 pr-3 text-sm placeholder:text-muted-foreground focus:border-primary focus:outline-none"
/>
</div>
<Select placeholder="国家" value={country} onChange={(e) => setCountry(e.target.value)} options={COUNTRIES.map((c) => ({ value: c.code, label: `${c.flag} ${c.zh}` }))} className="w-32" />
<Select placeholder="渠道" value={channel} onChange={(e) => setChannel(e.target.value)} options={Object.entries(CHANNEL_META).map(([k, v]) => ({ value: k, label: v.name }))} className="w-28" />
<Select placeholder="阶段" value={stage} onChange={(e) => setStage(e.target.value)} options={CUSTOMER_STAGES.map((s) => ({ value: s.value, label: s.label }))} className="w-28" />
<Select placeholder="意向" value={intentLevel} onChange={(e) => setIntentLevel(e.target.value)} options={INTENT_LEVELS.map((l) => ({ value: l.value, label: l.label }))} className="w-28" />
<div className="flex items-center gap-1 rounded-md border border-border bg-card p-0.5 ml-auto">
{[
{ v: 'table' as const, icon: TableProperties },
{ v: 'card' as const, icon: LayoutGrid },
{ v: 'kanban' as const, icon: List },
].map((opt) => (
<button key={opt.v} onClick={() => setView(opt.v)} className={cn('rounded p-1.5', view === opt.v ? 'bg-muted text-foreground' : 'text-muted-foreground hover:text-foreground')}>
<opt.icon className="h-4 w-4" />
</button>
))}
</div>
</div>
}
/>
{/* 批量操作 */}
{selected.size > 0 && (
<div className="mb-3 flex items-center gap-2 rounded-lg bg-primary-50 px-3 py-2">
<span className="text-sm font-medium text-primary-700">已选 {selected.size} 位客户</span>
<div className="flex-1" />
<Button size="sm" variant="outline" onClick={batchAssign}><UserRoundPlus className="h-4 w-4" />批量分配</Button>
<Button size="sm" variant="outline" onClick={batchTag}><Tag className="h-4 w-4" />批量加标签</Button>
<Button size="sm" variant="ghost" onClick={() => setSelected(new Set())}>取消选择</Button>
</div>
)}
{/* 表格视图 */}
{view === 'table' && (
<div className="rounded-lg border border-border bg-card">
<Table>
<THead>
<Tr>
<Th className="w-10"><Checkbox checked={selected.size === list.length && list.length > 0} onCheckedChange={(v) => setSelected(v ? new Set(list.map((c: Customer) => c.id)) : new Set())} /></Th>
<Th>客户 · 公司</Th>
<Th>国家</Th>
<Th>联系人</Th>
<Th>来源</Th>
<Th>产品</Th>
<Th>意向</Th>
<Th>阶段</Th>
<Th>情绪</Th>
<Th>风险</Th>
<Th>负责人</Th>
<Th>最后联系</Th>
<Th className="w-12"></Th>
</Tr>
</THead>
<TBody>
{list.length === 0 && (
<Tr><Td colSpan={13}><EmptyState title="暂无匹配客户" description="尝试调整筛选条件" /></Td></Tr>
)}
{list.map((customer: Customer) => (
<Tr key={customer.id} className="cursor-pointer">
<Td onClick={(e) => e.stopPropagation()}><Checkbox checked={selected.has(customer.id)} onCheckedChange={(v) => setSelected((s) => { const ns = new Set(s); v ? ns.add(customer.id) : ns.delete(customer.id); return ns })} /></Td>
<Td onClick={() => navigate(`/customers/${customer.id}`)}>
<div className="flex items-center gap-2">
<Avatar name={customer.name} size="xs" />
<div>
<p className="text-sm font-medium hover:text-primary">{customer.name}</p>
{customer.company && <p className="text-xs text-muted-foreground">{customer.company}</p>}
</div>
{customer.isKey && <Badge variant="warning">⭐</Badge>}
</div>
</Td>
<Td onClick={() => navigate(`/customers/${customer.id}`)}><CountryFlag code={customer.country} /></Td>
<Td onClick={() => navigate(`/customers/${customer.id}`)}><span className="text-xs">{customer.email || customer.phone || '—'}</span></Td>
<Td onClick={() => navigate(`/customers/${customer.id}`)}><span className="flex items-center gap-1"><ChannelIcon channel={customer.channel} size="xs" withBg={false} />{customer.channel}</span></Td>
<Td onClick={() => navigate(`/customers/${customer.id}`)}><span className="text-xs">{customer.products[0] || '—'}</span></Td>
<Td><IntentScoreBadge score={customer.intentScore} /></Td>
<Td><CustomerStageBadge stage={customer.stage} /></Td>
<Td><SentimentBadge sentiment={customer.sentiment} emojiOnly /></Td>
<Td><RiskLevelBadge level={customer.riskLevel} /></Td>
<Td><span className="text-xs">{customer.ownerId === 'u-003' ? '李婷' : customer.ownerId === 'u-004' ? '赵磊' : customer.ownerId === 'u-005' ? '周敏' : customer.ownerId === 'u-008' ? '郑楠' : customer.ownerId ? '其他' : <span className="text-amber-600">未分配</span>}</span></Td>
<Td><span className="text-xs text-muted-foreground">{customer.lastContactAt ? new Date(customer.lastContactAt).toLocaleDateString('zh-CN') : '—'}</span></Td>
<Td>
<Dropdown
trigger={<button className="rounded p-1 hover:bg-muted"><MoreHorizontal className="h-4 w-4 text-muted-foreground" /></button>}
items={[
{ key: 'view', label: '查看详情', onSelect: () => navigate(`/customers/${customer.id}`) },
{ key: 'edit', label: '编辑', onSelect: () => { setEditCustomer(customer); setFormDialog(true) } },
{ key: 'delete', label: '删除', danger: true, onSelect: () => handleDelete(customer) },
]}
/>
</Td>
</Tr>
))}
</TBody>
</Table>
</div>
)}
{/* 卡片视图 */}
{view === 'card' && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{list.map((customer: Customer) => (
<Card key={customer.id} className="cursor-pointer p-4 hover:shadow-md transition-shadow" onClick={() => navigate(`/customers/${customer.id}`)}>
<div className="flex items-start justify-between">
<div className="flex items-center gap-2.5">
<Avatar name={customer.name} size="sm" />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{customer.name}</p>
<p className="text-xs text-muted-foreground">{customer.company || '—'}</p>
</div>
</div>
<ChannelIcon channel={customer.channel} size="xs" />
</div>
<div className="mt-3 flex items-center gap-2">
<CountryFlag code={customer.country} />
<IntentScoreBadge score={customer.intentScore} />
</div>
<div className="mt-2 flex flex-wrap gap-1">
<CustomerStageBadge stage={customer.stage} />
<SentimentBadge sentiment={customer.sentiment} emojiOnly />
{customer.riskLevel !== 'none' && <RiskLevelBadge level={customer.riskLevel} />}
</div>
{customer.products.length > 0 && (
<p className="mt-2 text-xs text-muted-foreground truncate">{customer.products[0]}</p>
)}
</Card>
))}
</div>
)}
{/* 看板视图 */}
{view === 'kanban' && (
<div className="flex gap-3 overflow-x-auto pb-2 scrollbar-thin">
{CUSTOMER_STAGES.map((s) => {
const cols = list.filter((c: Customer) => c.stage === s.value)
return (
<div key={s.value} className="w-64 shrink-0">
<div className="mb-2 flex items-center justify-between rounded-md bg-muted px-3 py-2">
<span className="text-sm font-medium">{s.label}</span>
<Badge>{cols.length}</Badge>
</div>
<div className="space-y-2">
{cols.map((customer: Customer) => (
<Card
key={customer.id}
className="cursor-pointer p-3 hover:shadow-sm"
onClick={() => navigate(`/customers/${customer.id}`)}
>
<div className="flex items-center gap-2">
<Avatar name={customer.name} size="xs" />
<span className="text-sm font-medium truncate">{customer.name}</span>
</div>
<div className="mt-1.5 flex items-center gap-2">
<CountryFlag code={customer.country} withName={false} />
<IntentScoreBadge score={customer.intentScore} className="scale-75" />
</div>
</Card>
))}
</div>
</div>
)
})}
</div>
)}
<CustomerFormDialog open={formDialog} onOpenChange={setFormDialog} customer={editCustomer} />
{/* 去重合并弹窗 */}
<Dialog open={mergeDialog} onOpenChange={setMergeDialog} title="重复客户检测" width="max-w-2xl">
{duplicateList.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">未发现明显的重复客户,所有档案均为唯一。</p>
) : (
duplicateList.map((dup: any) => (
<div key={dup.id} className="mb-3 rounded-lg border border-border p-3">
<p className="text-xs text-muted-foreground mb-2">检测原因:{dup.reason}</p>
<div className="flex items-center gap-3">
<div className="flex-1 rounded bg-muted p-2 text-sm">{dup.customers[0]?.name} · {dup.customers[0]?.email}</div>
<GitMerge className="h-4 w-4 text-muted-foreground" />
<div className="flex-1 rounded bg-muted p-2 text-sm">{dup.customers[1]?.name} · {dup.customers[1]?.email}</div>
</div>
<Button size="sm" className="mt-2" onClick={() => handleMerge(dup.customers[0]?.id, dup.customers[1]?.id)}>
合并为客户「{dup.customers[0]?.name}」
</Button>
</div>
))
)}
</Dialog>
{/* 导入弹窗 */}
<Dialog
open={importDialog}
onOpenChange={setImportDialog}
title="批量导入客户"
footer={<Button onClick={handleImport}>模拟导入 2 条演示数据</Button>}
>
<p className="text-sm text-muted-foreground">
支持 Excel(.xlsx)或 CSV 文件上传,系统将自动识别列名(姓名/公司/国家/邮箱/电话)并去重建档。
</p>
</Dialog>
</div>
)
}