chore(history): import initial web product snapshot
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user