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 = { online: { color: '#16a34a', text: '在线', dot: '#16a34a' }, offline: { color: '#94a3b8', text: '离线', dot: '#94a3b8' }, busy: { color: '#d97706', text: '忙碌', dot: '#d97706' }, } const tagColorMap: Record = { 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 = { 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 ( {label} ) } const StatusDot = ({ status }: { status: string }) => { const s = statusMap[status] || statusMap.offline return ( {s.text} ) } const Customers = () => { const { user } = useAuth() const navigate = useNavigate() const canDelete = user?.role === 'admin' || user?.role === 'supervisor' const [customers, setCustomers] = useState([]) 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(null) const [editingId, setEditingId] = useState(null) const [historySessions, setHistorySessions] = useState([]) 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([]) 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 (
{/* 顶栏标题 — 对齐设计稿 header */}

客户管理

{/* 主内容区 */}
{/* 工具栏:搜索 + 筛选 + 操作 同一行 */}
{ 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" />
{filterChips.map(chip => { const active = tagFilter === chip.key return ( ) })}
{/* 表格 + 分页 */}
{loading ? (
) : displayed.length === 0 ? ( ) : (
{['客户', '手机号', '邮箱', '来源渠道', '标签', '最后对话', '状态', '操作'].map((h, i) => ( ))} {displayed.map(record => { const pal = avatarPalette(record.name || '?') const selected = panelOpen && selectedCustomer?.id === record.id return ( openDetail(record)} className={`group cursor-pointer border-t border-neutral-100 transition-colors ${ selected ? 'bg-blue-50/50' : 'hover:bg-neutral-50' }`} > ) })}
{h}
{(record.name || '?').slice(0, 1)}
{record.name}
ID: C{String(record.id).padStart(5, '0')}
{maskPhone(record.phone)} {record.email || '—'} {record.source || '—'}
{parseTags(record.tags).length === 0 ? : parseTags(record.tags).map(t => )}
{relativeTime(record.last_contact_at)} e.stopPropagation()}>
)}
{/* 分页 — 在表格卡片外 */}
{total} 位客户
{ setPage(p) if (ps !== pageSize) { setPageSize(ps) setPage(1) } }} size="small" />
{/* 右侧详情面板 — 内嵌 360px,非遮罩 Drawer */} {panelOpen && selectedCustomer && ( )}
setEditOpen(false)} onOk={() => form.submit()} confirmLoading={saving} destroyOnClose okText="保存" >