724 lines
31 KiB
TypeScript
724 lines
31 KiB
TypeScript
import { useState, useEffect, useMemo } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import {
|
|
Input, Button, Empty, message, Modal, Form, Select, Popconfirm, Spin, Pagination,
|
|
} from 'antd'
|
|
import {
|
|
SearchOutlined, PhoneOutlined, MailOutlined, EditOutlined, PlusOutlined,
|
|
DeleteOutlined, ExportOutlined, CloseOutlined, GlobalOutlined, MessageOutlined,
|
|
} from '@ant-design/icons'
|
|
import {
|
|
createCustomer, deleteCustomer, exportCustomersCSV, getCustomer, getCustomerTags, getCustomers, updateCustomer,
|
|
type Customer, type CustomerTag, type Session,
|
|
} from '@/services/api'
|
|
import { useAuth } from '@/stores/auth'
|
|
|
|
const statusMap: Record<string, { color: string; text: string; dot: string }> = {
|
|
online: { color: '#16a34a', text: '在线', dot: '#16a34a' },
|
|
offline: { color: '#94a3b8', text: '离线', dot: '#94a3b8' },
|
|
busy: { color: '#d97706', text: '忙碌', dot: '#d97706' },
|
|
}
|
|
|
|
const tagColorMap: Record<string, { bg: string; color: string }> = {
|
|
amber: { bg: '#fef3c7', color: '#92400e' },
|
|
green: { bg: '#f0fdf4', color: '#16a34a' },
|
|
blue: { bg: '#dbeafe', color: '#2563eb' },
|
|
cyan: { bg: '#ecfeff', color: '#0891b2' },
|
|
violet: { bg: '#f3e8ff', color: '#7c3aed' },
|
|
rose: { bg: '#fff1f2', color: '#e11d48' },
|
|
orange: { bg: '#fffbeb', color: '#d97706' },
|
|
slate: { bg: '#f1f5f9', color: '#475569' },
|
|
// 兼容历史写死名称
|
|
'VIP客户': { bg: '#fef3c7', color: '#92400e' },
|
|
'VIP': { bg: '#fef3c7', color: '#92400e' },
|
|
'新客户': { bg: '#f0fdf4', color: '#16a34a' },
|
|
'活跃': { bg: '#dbeafe', color: '#2563eb' },
|
|
'沉默': { bg: '#fffbeb', color: '#d97706' },
|
|
'企业客户': { bg: '#ecfeff', color: '#0891b2' },
|
|
}
|
|
|
|
/** 浅底深字头像色,对齐效果图 */
|
|
const avatarPalettes = [
|
|
{ bg: '#dbeafe', color: '#2563eb' },
|
|
{ bg: '#fce7f3', color: '#be185d' },
|
|
{ bg: '#ede9fe', color: '#6d28d9' },
|
|
{ bg: '#d1fae5', color: '#047857' },
|
|
{ bg: '#fef3c7', color: '#b45309' },
|
|
{ bg: '#e0e7ff', color: '#4338ca' },
|
|
{ bg: '#ffedd5', color: '#c2410c' },
|
|
{ bg: '#f0fdf4', color: '#166534' },
|
|
]
|
|
|
|
function parseTags(tagsStr: string): string[] {
|
|
try {
|
|
const parsed = JSON.parse(tagsStr)
|
|
return Array.isArray(parsed) ? parsed : []
|
|
} catch {
|
|
return tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : []
|
|
}
|
|
}
|
|
|
|
function relativeTime(iso?: string | null) {
|
|
if (!iso) return '—'
|
|
const diff = Date.now() - new Date(iso).getTime()
|
|
const mins = Math.floor(diff / 60000)
|
|
if (mins < 1) return '刚刚'
|
|
if (mins < 60) return `${mins} 分钟前`
|
|
const hours = Math.floor(mins / 60)
|
|
if (hours < 24) return `${hours} 小时前`
|
|
const days = Math.floor(hours / 24)
|
|
if (days < 30) return `${days} 天前`
|
|
return new Date(iso).toLocaleDateString('zh-CN')
|
|
}
|
|
|
|
function avatarPalette(name: string) {
|
|
let h = 0
|
|
for (let i = 0; i < name.length; i++) h = name.charCodeAt(i) + ((h << 5) - h)
|
|
return avatarPalettes[Math.abs(h) % avatarPalettes.length]
|
|
}
|
|
|
|
function maskPhone(phone?: string) {
|
|
if (!phone) return '—'
|
|
const digits = phone.replace(/\D/g, '')
|
|
if (digits.length === 11) return `${digits.slice(0, 3)}****${digits.slice(7)}`
|
|
return phone
|
|
}
|
|
|
|
/** 左侧活跃度色条:在线偏亮,沉默偏灰 */
|
|
function activityBar(c: Customer): string {
|
|
const tags = parseTags(c.tags)
|
|
if (c.status === 'online') return 'linear-gradient(to top, #93c5fd, #2563eb)'
|
|
if (tags.includes('沉默')) return 'linear-gradient(to top, #f1f5f9, #cbd5e1)'
|
|
if (c.status === 'busy') return 'linear-gradient(to top, #fde68a, #d97706)'
|
|
const t = c.last_contact_at ? Date.now() - new Date(c.last_contact_at).getTime() : Infinity
|
|
if (t < 3600000) return 'linear-gradient(to top, #bfdbfe, #3b82f6)'
|
|
if (t < 86400000) return 'linear-gradient(to top, #e2e8f0, #60a5fa)'
|
|
return 'linear-gradient(to top, #f1f5f9, #cbd5e1)'
|
|
}
|
|
|
|
function sessionTopic(s: Session) {
|
|
if (s.last_message && s.last_message.length > 0 && s.last_message.length <= 12 && !s.last_message.startsWith('http')) {
|
|
return s.last_message
|
|
}
|
|
const map: Record<string, string> = {
|
|
active: '进行中会话', waiting: '等待接入', ended: '已结束会话', archived: '归档会话',
|
|
}
|
|
return map[s.status] || `会话 #${s.id}`
|
|
}
|
|
|
|
const TagPill = ({ tag, catalog }: { tag: string; catalog?: CustomerTag[] }) => {
|
|
const meta = catalog?.find(t => t.name === tag)
|
|
const byColor = meta?.color ? tagColorMap[meta.color] : undefined
|
|
const s = byColor || tagColorMap[tag] || { bg: '#f1f5f9', color: '#475569' }
|
|
const label = tag === 'VIP客户' ? 'VIP' : tag
|
|
return (
|
|
<span
|
|
className="inline-flex items-center px-2 py-0.5 rounded text-[11px] font-medium whitespace-nowrap"
|
|
style={{ backgroundColor: s.bg, color: s.color }}
|
|
>
|
|
{label}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
const StatusDot = ({ status }: { status: string }) => {
|
|
const s = statusMap[status] || statusMap.offline
|
|
return (
|
|
<span className="inline-flex items-center gap-1.5">
|
|
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: s.dot }} />
|
|
<span className="text-[12px] whitespace-nowrap" style={{ color: s.color }}>{s.text}</span>
|
|
</span>
|
|
)
|
|
}
|
|
|
|
const Customers = () => {
|
|
const { user } = useAuth()
|
|
const navigate = useNavigate()
|
|
const canDelete = user?.role === 'admin' || user?.role === 'supervisor'
|
|
const [customers, setCustomers] = useState<Customer[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [total, setTotal] = useState(0)
|
|
const [page, setPage] = useState(1)
|
|
const [pageSize, setPageSize] = useState(10)
|
|
const [search, setSearch] = useState('')
|
|
const [exporting, setExporting] = useState(false)
|
|
const [tagFilter, setTagFilter] = useState('all')
|
|
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null)
|
|
const [editingId, setEditingId] = useState<number | null>(null)
|
|
const [historySessions, setHistorySessions] = useState<Session[]>([])
|
|
const [detailLoading, setDetailLoading] = useState(false)
|
|
const [panelOpen, setPanelOpen] = useState(false)
|
|
const [editOpen, setEditOpen] = useState(false)
|
|
const [saving, setSaving] = useState(false)
|
|
const [form] = Form.useForm()
|
|
const [tagCatalog, setTagCatalog] = useState<CustomerTag[]>([])
|
|
|
|
useEffect(() => {
|
|
loadCustomers()
|
|
}, [page, pageSize, search])
|
|
|
|
useEffect(() => {
|
|
getCustomerTags()
|
|
.then(res => setTagCatalog(Array.isArray(res.data) ? res.data : []))
|
|
.catch(() => setTagCatalog([]))
|
|
}, [])
|
|
|
|
const filterChips = useMemo(() => {
|
|
const chips = [{ key: 'all', label: '全部' }]
|
|
tagCatalog.forEach(t => {
|
|
chips.push({
|
|
key: t.name,
|
|
label: t.name === 'VIP客户' ? 'VIP' : t.name,
|
|
})
|
|
})
|
|
return chips
|
|
}, [tagCatalog])
|
|
|
|
const tagSelectOptions = useMemo(
|
|
() => tagCatalog.map(t => ({ value: t.name, label: t.name })),
|
|
[tagCatalog],
|
|
)
|
|
|
|
const loadCustomers = async () => {
|
|
setLoading(true)
|
|
try {
|
|
const res = await getCustomers({ search, page, pageSize })
|
|
setCustomers(res.list)
|
|
setTotal(res.total)
|
|
} catch {
|
|
setCustomers([])
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const displayed = useMemo(() => {
|
|
if (tagFilter === 'all') return customers
|
|
return customers.filter(c =>
|
|
parseTags(c.tags).some(t => t === tagFilter || (tagFilter === 'VIP客户' && (t === 'VIP' || t === 'VIP客户'))),
|
|
)
|
|
}, [customers, tagFilter])
|
|
|
|
const openDetail = async (record: Customer) => {
|
|
setSelectedCustomer(record)
|
|
setPanelOpen(true)
|
|
setHistorySessions([])
|
|
setDetailLoading(true)
|
|
try {
|
|
const res = await getCustomer(record.id)
|
|
setSelectedCustomer(res.data.customer)
|
|
setHistorySessions(Array.isArray(res.data.sessions) ? res.data.sessions : [])
|
|
} catch {
|
|
// keep list snapshot
|
|
} finally {
|
|
setDetailLoading(false)
|
|
}
|
|
}
|
|
|
|
const closePanel = () => {
|
|
setPanelOpen(false)
|
|
}
|
|
|
|
const openCreate = () => {
|
|
setEditingId(null)
|
|
form.resetFields()
|
|
form.setFieldsValue({ status: 'offline', source: '手动录入', tags: [] })
|
|
setEditOpen(true)
|
|
}
|
|
|
|
const openEdit = (customer: Customer) => {
|
|
setEditingId(customer.id)
|
|
form.setFieldsValue({
|
|
name: customer.name,
|
|
phone: customer.phone,
|
|
email: customer.email,
|
|
source: customer.source,
|
|
status: customer.status,
|
|
tags: parseTags(customer.tags),
|
|
})
|
|
setEditOpen(true)
|
|
}
|
|
|
|
const handleSave = async (values: {
|
|
name: string; phone?: string; email?: string; source?: string; status?: string; tags?: string[]
|
|
}) => {
|
|
setSaving(true)
|
|
try {
|
|
const payload = {
|
|
name: values.name.trim(),
|
|
phone: values.phone?.trim() || '',
|
|
email: values.email?.trim() || '',
|
|
source: values.source?.trim() || '手动录入',
|
|
status: values.status || 'offline',
|
|
tags: JSON.stringify(values.tags || []),
|
|
}
|
|
if (editingId) {
|
|
const res = await updateCustomer(editingId, payload)
|
|
message.success('客户已更新')
|
|
setSelectedCustomer(res.data)
|
|
setEditOpen(false)
|
|
await loadCustomers()
|
|
if (panelOpen) await openDetail(res.data)
|
|
} else {
|
|
await createCustomer(payload)
|
|
message.success('客户已创建')
|
|
setEditOpen(false)
|
|
setPage(1)
|
|
await loadCustomers()
|
|
}
|
|
} catch (e) {
|
|
message.error(e instanceof Error ? e.message : '保存失败')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const handleDelete = async (id: number) => {
|
|
try {
|
|
await deleteCustomer(id)
|
|
message.success('已删除')
|
|
if (selectedCustomer?.id === id) {
|
|
setPanelOpen(false)
|
|
setSelectedCustomer(null)
|
|
}
|
|
await loadCustomers()
|
|
} catch (e) {
|
|
message.error(e instanceof Error ? e.message : '删除失败')
|
|
}
|
|
}
|
|
|
|
const pendingCount = historySessions.filter(s => s.status === 'waiting' || s.status === 'active').length
|
|
const avgSatisfaction = (() => {
|
|
const scores = historySessions
|
|
.map(s => s.satisfaction_score)
|
|
.filter((n): n is number => typeof n === 'number' && n > 0)
|
|
if (scores.length === 0) return '—'
|
|
return (scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(1)
|
|
})()
|
|
|
|
return (
|
|
<div className="h-full flex flex-col min-h-0 bg-neutral-50 overflow-hidden">
|
|
{/* 顶栏标题 — 对齐设计稿 header */}
|
|
<header className="shrink-0 h-14 px-6 flex items-center justify-between bg-white border-b border-neutral-200">
|
|
<h1 className="text-base font-semibold text-neutral-800 m-0">客户管理</h1>
|
|
</header>
|
|
|
|
<div className="flex flex-1 min-h-0 overflow-hidden">
|
|
{/* 主内容区 */}
|
|
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
|
{/* 工具栏:搜索 + 筛选 + 操作 同一行 */}
|
|
<div className="shrink-0 px-6 py-4 bg-white">
|
|
<div className="flex items-center gap-3 flex-wrap">
|
|
<div
|
|
className="flex items-center gap-2 px-3 h-9 rounded-lg max-w-xs w-full sm:w-64 border border-neutral-200"
|
|
style={{ backgroundColor: 'var(--neutral-100)' }}
|
|
>
|
|
<SearchOutlined className="text-neutral-400 shrink-0 text-sm" />
|
|
<input
|
|
type="text"
|
|
placeholder="搜索客户名称、手机号、邮箱..."
|
|
value={search}
|
|
onChange={e => { setSearch(e.target.value); setPage(1) }}
|
|
className="flex-1 bg-transparent outline-none text-[13px] min-w-0 text-neutral-700 placeholder:text-neutral-400 border-0"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1.5 flex-wrap">
|
|
{filterChips.map(chip => {
|
|
const active = tagFilter === chip.key
|
|
return (
|
|
<button
|
|
key={chip.key}
|
|
type="button"
|
|
onClick={() => setTagFilter(chip.key)}
|
|
className={`px-3 py-1.5 rounded-md text-[13px] font-medium whitespace-nowrap border-0 cursor-pointer transition-colors ${
|
|
active
|
|
? 'bg-[#2563eb] text-white'
|
|
: 'bg-transparent text-neutral-600 hover:bg-neutral-100'
|
|
}`}
|
|
>
|
|
{chip.label}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 ml-auto">
|
|
<Button
|
|
icon={<ExportOutlined />}
|
|
loading={exporting}
|
|
className="!text-[13px] !h-8 !px-3 !font-medium !text-neutral-600 !border-neutral-200"
|
|
onClick={async () => {
|
|
setExporting(true)
|
|
try {
|
|
await exportCustomersCSV({ search: search || undefined })
|
|
message.success('客户列表已导出')
|
|
} catch (e) {
|
|
message.error(e instanceof Error ? e.message : '导出失败')
|
|
} finally {
|
|
setExporting(false)
|
|
}
|
|
}}
|
|
>
|
|
导出
|
|
</Button>
|
|
<Button
|
|
type="primary"
|
|
icon={<PlusOutlined />}
|
|
className="!text-[13px] !h-8 !px-3 !font-medium !bg-[#2563eb]"
|
|
onClick={openCreate}
|
|
>
|
|
添加客户
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 表格 + 分页 */}
|
|
<div className="flex-1 min-h-0 overflow-auto px-6 pb-4">
|
|
<div className="rounded-lg overflow-hidden border border-neutral-200 bg-white">
|
|
{loading ? (
|
|
<div className="py-24 flex justify-center"><Spin /></div>
|
|
) : displayed.length === 0 ? (
|
|
<Empty className="py-16" description="暂无客户数据" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full" style={{ minWidth: 960 }}>
|
|
<thead>
|
|
<tr className="bg-neutral-50">
|
|
{['客户', '手机号', '邮箱', '来源渠道', '标签', '最后对话', '状态', '操作'].map((h, i) => (
|
|
<th
|
|
key={h}
|
|
className={`py-3 px-3 text-[12px] font-medium uppercase tracking-wide whitespace-nowrap text-neutral-500 ${
|
|
i === 7 ? 'text-right' : 'text-left'
|
|
}`}
|
|
style={{
|
|
width: [200, 120, 160, 100, 130, 110, 80, 110][i],
|
|
}}
|
|
>
|
|
{h}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{displayed.map(record => {
|
|
const pal = avatarPalette(record.name || '?')
|
|
const selected = panelOpen && selectedCustomer?.id === record.id
|
|
return (
|
|
<tr
|
|
key={record.id}
|
|
onClick={() => openDetail(record)}
|
|
className={`group cursor-pointer border-t border-neutral-100 transition-colors ${
|
|
selected ? 'bg-blue-50/50' : 'hover:bg-neutral-50'
|
|
}`}
|
|
>
|
|
<td className="py-3 px-3 relative">
|
|
<div
|
|
className="absolute left-0 top-0 bottom-0 w-[3px]"
|
|
style={{ background: activityBar(record) }}
|
|
/>
|
|
<div className="flex items-center gap-2.5 min-w-0 pl-1">
|
|
<div
|
|
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-semibold shrink-0"
|
|
style={{ backgroundColor: pal.bg, color: pal.color }}
|
|
>
|
|
{(record.name || '?').slice(0, 1)}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<div className="text-[13px] font-medium text-neutral-800 truncate">{record.name}</div>
|
|
<div className="text-[11px] text-neutral-400 truncate">
|
|
ID: C{String(record.id).padStart(5, '0')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="py-3 px-3 text-[13px] text-neutral-600 whitespace-nowrap">
|
|
{maskPhone(record.phone)}
|
|
</td>
|
|
<td className="py-3 px-3 text-[13px] text-neutral-600 max-w-[160px] truncate">
|
|
{record.email || '—'}
|
|
</td>
|
|
<td className="py-3 px-3 text-[13px] text-neutral-600 whitespace-nowrap">
|
|
{record.source || '—'}
|
|
</td>
|
|
<td className="py-3 px-3">
|
|
<div className="flex items-center gap-1.5 flex-wrap">
|
|
{parseTags(record.tags).length === 0
|
|
? <span className="text-neutral-300 text-xs">—</span>
|
|
: parseTags(record.tags).map(t => <TagPill key={t} tag={t} catalog={tagCatalog} />)}
|
|
</div>
|
|
</td>
|
|
<td className="py-3 px-3 text-[13px] text-neutral-500 whitespace-nowrap">
|
|
{relativeTime(record.last_contact_at)}
|
|
</td>
|
|
<td className="py-3 px-3">
|
|
<StatusDot status={record.status} />
|
|
</td>
|
|
<td className="py-3 px-3 text-right" onClick={e => e.stopPropagation()}>
|
|
<div className="flex items-center justify-end gap-1">
|
|
<button
|
|
type="button"
|
|
className="px-2 py-1 rounded text-[12px] font-medium text-[#2563eb] hover:bg-blue-50 border-0 bg-transparent cursor-pointer"
|
|
onClick={() => openDetail(record)}
|
|
>
|
|
查看
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="px-2 py-1 rounded text-[12px] font-medium text-neutral-500 hover:bg-neutral-100 border-0 bg-transparent cursor-pointer"
|
|
onClick={() => openEdit(record)}
|
|
>
|
|
编辑
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 分页 — 在表格卡片外 */}
|
|
<div className="flex items-center justify-between mt-4 flex-wrap gap-3">
|
|
<div className="text-[13px] text-neutral-500">
|
|
共 <span className="font-medium text-neutral-700">{total}</span> 位客户
|
|
</div>
|
|
<Pagination
|
|
current={page}
|
|
total={total}
|
|
pageSize={pageSize}
|
|
showSizeChanger
|
|
pageSizeOptions={[10, 20, 50]}
|
|
onChange={(p, ps) => {
|
|
setPage(p)
|
|
if (ps !== pageSize) {
|
|
setPageSize(ps)
|
|
setPage(1)
|
|
}
|
|
}}
|
|
size="small"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 右侧详情面板 — 内嵌 360px,非遮罩 Drawer */}
|
|
{panelOpen && selectedCustomer && (
|
|
<aside className="w-[360px] shrink-0 flex flex-col bg-white border-l border-neutral-200 overflow-hidden">
|
|
<div className="flex items-center justify-between px-5 py-4 shrink-0 border-b border-neutral-200">
|
|
<h2 className="text-[15px] font-semibold text-neutral-800 m-0">客户详情</h2>
|
|
<button
|
|
type="button"
|
|
className="w-7 h-7 rounded-md flex items-center justify-center text-neutral-400 hover:bg-neutral-100 border-0 bg-transparent cursor-pointer"
|
|
onClick={closePanel}
|
|
>
|
|
<CloseOutlined className="text-xs" />
|
|
</button>
|
|
</div>
|
|
|
|
{detailLoading ? (
|
|
<div className="flex-1 flex items-center justify-center"><Spin /></div>
|
|
) : (
|
|
<div className="flex-1 overflow-y-auto">
|
|
{/* 头像区 */}
|
|
{(() => {
|
|
const pal = avatarPalette(selectedCustomer.name)
|
|
return (
|
|
<div className="px-5 py-5 text-center border-b border-neutral-100">
|
|
<div
|
|
className="w-16 h-16 rounded-full flex items-center justify-center text-xl font-bold mx-auto mb-3"
|
|
style={{ backgroundColor: pal.bg, color: pal.color }}
|
|
>
|
|
{selectedCustomer.name.slice(0, 1)}
|
|
</div>
|
|
<div className="text-base font-semibold text-neutral-800 mb-0.5">{selectedCustomer.name}</div>
|
|
<div className="text-[13px] text-neutral-500 mb-2">
|
|
ID: C{String(selectedCustomer.id).padStart(5, '0')}
|
|
</div>
|
|
<div className="flex items-center justify-center">
|
|
<StatusDot status={selectedCustomer.status} />
|
|
</div>
|
|
</div>
|
|
)
|
|
})()}
|
|
|
|
{/* 标签 */}
|
|
<div className="px-5 py-3 border-b border-neutral-100">
|
|
<div className="text-[11px] font-medium uppercase tracking-wide text-neutral-400 mb-2">标签</div>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{parseTags(selectedCustomer.tags).length === 0 ? (
|
|
<span className="text-xs text-neutral-400">暂无标签</span>
|
|
) : parseTags(selectedCustomer.tags).map(t => <TagPill key={t} tag={t} catalog={tagCatalog} />)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 联系方式 */}
|
|
<div className="px-5 py-3 border-b border-neutral-100">
|
|
<div className="text-[11px] font-medium uppercase tracking-wide text-neutral-400 mb-2">联系方式</div>
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2.5">
|
|
<PhoneOutlined className="text-neutral-400 text-[13px]" />
|
|
<span className="text-[13px] text-neutral-700">
|
|
{selectedCustomer.phone || '—'}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2.5 min-w-0">
|
|
<MailOutlined className="text-neutral-400 text-[13px] shrink-0" />
|
|
<span className="text-[13px] text-neutral-700 truncate">
|
|
{selectedCustomer.email || '—'}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2.5">
|
|
<GlobalOutlined className="text-neutral-400 text-[13px]" />
|
|
<span className="text-[13px] text-neutral-700 whitespace-nowrap">
|
|
来源: {selectedCustomer.source || '—'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 活跃数据 — 三列 */}
|
|
<div className="px-5 py-3 border-b border-neutral-100">
|
|
<div className="text-[11px] font-medium uppercase tracking-wide text-neutral-400 mb-2">活跃数据</div>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
{[
|
|
{ value: selectedCustomer.conversation_count, label: '对话次数' },
|
|
{ value: avgSatisfaction, label: '满意度' },
|
|
{ value: pendingCount, label: '待处理' },
|
|
].map(item => (
|
|
<div key={item.label} className="text-center py-2 rounded-md bg-neutral-50">
|
|
<div className="text-lg font-bold text-[#2563eb]">{item.value}</div>
|
|
<div className="text-[11px] text-neutral-500">{item.label}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 最近对话 */}
|
|
<div className="px-5 py-3 border-b border-neutral-100">
|
|
<div className="text-[11px] font-medium uppercase tracking-wide text-neutral-400 mb-2">最近对话</div>
|
|
<div className="space-y-2.5">
|
|
{historySessions.length === 0 ? (
|
|
<div className="text-xs text-neutral-400 py-3 text-center">暂无会话记录</div>
|
|
) : historySessions.slice(0, 6).map(s => (
|
|
<div key={s.id} className="flex items-start gap-2.5">
|
|
<div className="w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-semibold shrink-0 mt-0.5 bg-[#dbeafe] text-[#2563eb]">
|
|
客
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="text-[12px] font-medium text-neutral-700 truncate">
|
|
{sessionTopic(s)}
|
|
</span>
|
|
<span className="text-[11px] text-neutral-400 whitespace-nowrap shrink-0">
|
|
{relativeTime(s.last_message_at || s.created_at)}
|
|
</span>
|
|
</div>
|
|
<p className="text-[12px] mt-0.5 text-neutral-500 line-clamp-2 m-0">
|
|
{s.last_message || statusMap[s.status]?.text || s.status}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 底部操作 — 发起对话 + 编辑信息 */}
|
|
<div className="px-5 py-4 shrink-0 mt-auto border-t border-neutral-100">
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-[13px] font-medium bg-[#2563eb] text-white border-0 cursor-pointer hover:bg-[#1d4ed8] transition-colors"
|
|
onClick={() => {
|
|
message.info('请在工作台接待访客会话')
|
|
navigate('/agent/dashboard')
|
|
}}
|
|
>
|
|
<MessageOutlined className="text-sm" />
|
|
发起对话
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-[13px] font-medium border border-neutral-200 text-neutral-600 bg-white cursor-pointer hover:bg-neutral-50 transition-colors"
|
|
onClick={() => openEdit(selectedCustomer)}
|
|
>
|
|
<EditOutlined className="text-sm" />
|
|
编辑信息
|
|
</button>
|
|
</div>
|
|
{canDelete && (
|
|
<Popconfirm title="确认删除该客户?" onConfirm={() => handleDelete(selectedCustomer.id)}>
|
|
<button
|
|
type="button"
|
|
className="w-full mt-2 flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-md text-[12px] text-red-500 border-0 bg-transparent cursor-pointer hover:bg-red-50"
|
|
>
|
|
<DeleteOutlined />
|
|
删除客户
|
|
</button>
|
|
</Popconfirm>
|
|
)}
|
|
</div>
|
|
</aside>
|
|
)}
|
|
</div>
|
|
|
|
<Modal
|
|
title={editingId ? '编辑客户' : '添加客户'}
|
|
open={editOpen}
|
|
onCancel={() => setEditOpen(false)}
|
|
onOk={() => form.submit()}
|
|
confirmLoading={saving}
|
|
destroyOnClose
|
|
okText="保存"
|
|
>
|
|
<Form form={form} layout="vertical" className="mt-3" onFinish={handleSave}>
|
|
<Form.Item name="name" label="客户名称" rules={[{ required: true, message: '请输入名称' }, { min: 2, max: 50, message: '2-50 个字符' }]}>
|
|
<Input maxLength={50} placeholder="姓名或昵称" />
|
|
</Form.Item>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Form.Item name="phone" label="手机号" rules={[{ pattern: /^$|^1[3-9]\d{9}$/, message: '手机号格式不正确' }]}>
|
|
<Input maxLength={20} placeholder="可选" />
|
|
</Form.Item>
|
|
<Form.Item name="status" label="状态">
|
|
<Select options={Object.entries(statusMap).map(([k, v]) => ({ value: k, label: v.text }))} />
|
|
</Form.Item>
|
|
</div>
|
|
<Form.Item name="email" label="邮箱" rules={[{ type: 'email', message: '邮箱格式不正确' }]}>
|
|
<Input maxLength={100} placeholder="可选" />
|
|
</Form.Item>
|
|
<Form.Item name="source" label="来源渠道">
|
|
<Input maxLength={30} placeholder="如:官网咨询、微信、APP" />
|
|
</Form.Item>
|
|
<Form.Item
|
|
name="tags"
|
|
label="标签"
|
|
extra={
|
|
tagSelectOptions.length === 0
|
|
? '暂无标签库,请管理员在「系统设置 → 客户标签」中创建'
|
|
: '仅可从标签库中选择(由管理员维护)'
|
|
}
|
|
>
|
|
<Select
|
|
mode="multiple"
|
|
maxCount={10}
|
|
allowClear
|
|
options={tagSelectOptions}
|
|
placeholder={tagSelectOptions.length === 0 ? '请先配置标签库' : '选择标签'}
|
|
disabled={tagSelectOptions.length === 0}
|
|
optionFilterProp="label"
|
|
/>
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default Customers
|