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>(new Set()) const [formDialog, setFormDialog] = useState(false) const [editCustomer, setEditCustomer] = useState(null) const [importDialog, setImportDialog] = useState(false) const [mergeDialog, setMergeDialog] = useState(false) const [duplicateList, setDuplicateList] = useState([]) 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 }) => 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 if (isError) return refetch()} /> return (
} extra={
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" />
setChannel(e.target.value)} options={Object.entries(CHANNEL_META).map(([k, v]) => ({ value: k, label: v.name }))} className="w-28" /> setIntentLevel(e.target.value)} options={INTENT_LEVELS.map((l) => ({ value: l.value, label: l.label }))} className="w-28" />
{[ { v: 'table' as const, icon: TableProperties }, { v: 'card' as const, icon: LayoutGrid }, { v: 'kanban' as const, icon: List }, ].map((opt) => ( ))}
} /> {/* 批量操作 */} {selected.size > 0 && (
已选 {selected.size} 位客户
)} {/* 表格视图 */} {view === 'table' && (
{list.length === 0 && ( )} {list.map((customer: Customer) => ( ))}
0} onCheckedChange={(v) => setSelected(v ? new Set(list.map((c: Customer) => c.id)) : new Set())} /> 客户 · 公司 国家 联系人 来源 产品 意向 阶段 情绪 风险 负责人 最后联系
e.stopPropagation()}> setSelected((s) => { const ns = new Set(s); v ? ns.add(customer.id) : ns.delete(customer.id); return ns })} /> navigate(`/customers/${customer.id}`)}>

{customer.name}

{customer.company &&

{customer.company}

}
{customer.isKey && ⭐}
navigate(`/customers/${customer.id}`)}> navigate(`/customers/${customer.id}`)}>{customer.email || customer.phone || '—'} navigate(`/customers/${customer.id}`)}>{customer.channel} navigate(`/customers/${customer.id}`)}>{customer.products[0] || '—'} {customer.ownerId === 'u-003' ? '李婷' : customer.ownerId === 'u-004' ? '赵磊' : customer.ownerId === 'u-005' ? '周敏' : customer.ownerId === 'u-008' ? '郑楠' : customer.ownerId ? '其他' : 未分配} {customer.lastContactAt ? new Date(customer.lastContactAt).toLocaleDateString('zh-CN') : '—'} } 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) }, ]} />
)} {/* 卡片视图 */} {view === 'card' && (
{list.map((customer: Customer) => ( navigate(`/customers/${customer.id}`)}>

{customer.name}

{customer.company || '—'}

{customer.riskLevel !== 'none' && }
{customer.products.length > 0 && (

{customer.products[0]}

)}
))}
)} {/* 看板视图 */} {view === 'kanban' && (
{CUSTOMER_STAGES.map((s) => { const cols = list.filter((c: Customer) => c.stage === s.value) return (
{s.label} {cols.length}
{cols.map((customer: Customer) => ( navigate(`/customers/${customer.id}`)} >
{customer.name}
))}
) })}
)} {/* 去重合并弹窗 */} {duplicateList.length === 0 ? (

未发现明显的重复客户,所有档案均为唯一。

) : ( duplicateList.map((dup: any) => (

检测原因:{dup.reason}

{dup.customers[0]?.name} · {dup.customers[0]?.email}
{dup.customers[1]?.name} · {dup.customers[1]?.email}
)) )}
{/* 导入弹窗 */} 模拟导入 2 条演示数据} >

支持 Excel(.xlsx)或 CSV 文件上传,系统将自动识别列名(姓名/公司/国家/邮箱/电话)并去重建档。

) }