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
+402
View File
@@ -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>
)
}