diff --git a/web/src/components/layout/AgentLayout.tsx b/web/src/components/layout/AgentLayout.tsx index 704be88..1e5cc8c 100644 --- a/web/src/components/layout/AgentLayout.tsx +++ b/web/src/components/layout/AgentLayout.tsx @@ -5,7 +5,7 @@ const AgentLayout = () => { return (
-
+
diff --git a/web/src/pages/agent/Customers.tsx b/web/src/pages/agent/Customers.tsx index 71e3004..41bb2e3 100644 --- a/web/src/pages/agent/Customers.tsx +++ b/web/src/pages/agent/Customers.tsx @@ -1,26 +1,55 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useMemo } from 'react' +import { useNavigate } from 'react-router-dom' import { - Table, Input, Select, Tag, Drawer, Button, Space, Badge, Descriptions, Empty, - message, Modal, Form, Popconfirm, + Input, Button, Empty, message, Modal, Form, Select, Popconfirm, Spin, Pagination, } from 'antd' -import { SearchOutlined, PhoneOutlined, MailOutlined, EditOutlined, PlusOutlined, DeleteOutlined } from '@ant-design/icons' +import { + SearchOutlined, PhoneOutlined, MailOutlined, EditOutlined, PlusOutlined, + DeleteOutlined, ExportOutlined, CloseOutlined, GlobalOutlined, MessageOutlined, +} from '@ant-design/icons' import { createCustomer, deleteCustomer, getCustomer, getCustomers, updateCustomer, type Customer, type Session, } from '@/services/api' import { useAuth } from '@/stores/auth' -const statusMap: Record = { - online: { color: 'green', text: '在线' }, - offline: { color: 'default', text: '离线' }, - busy: { color: 'orange', text: '忙碌' }, +const statusMap: Record = { + online: { color: '#16a34a', text: '在线', dot: '#16a34a' }, + offline: { color: '#94a3b8', text: '离线', dot: '#94a3b8' }, + busy: { color: '#d97706', text: '忙碌', dot: '#d97706' }, } const tagOptions = ['VIP客户', '新客户', '活跃', '沉默', '企业客户'] -const tagColors: Record = { - 'VIP客户': 'gold', '新客户': 'blue', '活跃': 'green', '沉默': 'default', '企业客户': 'purple', +const filterChips = [ + { key: 'all', label: '全部' }, + { key: 'VIP客户', label: 'VIP' }, + { key: '新客户', label: '新客户' }, + { key: '活跃', label: '活跃' }, + { key: '沉默', label: '沉默' }, +] + +const tagStyle: Record = { + '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' }, + '高价值': { bg: '#f1f5f9', color: '#475569' }, } +/** 浅底深字头像色,对齐效果图 */ +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) @@ -30,31 +59,105 @@ function parseTags(tagsStr: string): string[] { } } +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 }: { tag: string }) => { + const s = tagStyle[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 [statusFilter, setStatusFilter] = useState() + const [tagFilter, setTagFilter] = useState('all') const [selectedCustomer, setSelectedCustomer] = useState(null) const [editingId, setEditingId] = useState(null) const [historySessions, setHistorySessions] = useState([]) - const [drawerOpen, setDrawerOpen] = useState(false) + const [detailLoading, setDetailLoading] = useState(false) + const [panelOpen, setPanelOpen] = useState(false) const [editOpen, setEditOpen] = useState(false) const [saving, setSaving] = useState(false) const [form] = Form.useForm() useEffect(() => { loadCustomers() - }, [page, search, statusFilter]) + }, [page, pageSize, search]) const loadCustomers = async () => { setLoading(true) try { - const res = await getCustomers({ search, status: statusFilter, page, pageSize: 10 }) + const res = await getCustomers({ search, page, pageSize }) setCustomers(res.list) setTotal(res.total) } catch { @@ -64,19 +167,33 @@ const Customers = () => { } } + 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) - setDrawerOpen(true) + 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() @@ -116,7 +233,7 @@ const Customers = () => { setSelectedCustomer(res.data) setEditOpen(false) await loadCustomers() - if (drawerOpen) await openDetail(res.data) + if (panelOpen) await openDetail(res.data) } else { await createCustomer(payload) message.success('客户已创建') @@ -136,7 +253,7 @@ const Customers = () => { await deleteCustomer(id) message.success('已删除') if (selectedCustomer?.id === id) { - setDrawerOpen(false) + setPanelOpen(false) setSelectedCustomer(null) } await loadCustomers() @@ -145,183 +262,402 @@ const Customers = () => { } } - const columns = [ - { - title: '客户名称', dataIndex: 'name', key: 'name', - render: (text: string, record: Customer) => ( - openDetail(record)}>{text} - ), - }, - { - title: '联系方式', key: 'contact', - render: (_: unknown, record: Customer) => ( -
- {record.phone &&
{record.phone}
} - {record.email &&
{record.email}
} - {!record.phone && !record.email && } -
- ), - }, - { - title: '标签', dataIndex: 'tags', key: 'tags', - render: (tags: string) => ( - - {parseTags(tags).map((t: string) => {t})} - - ), - }, - { - title: '状态', dataIndex: 'status', key: 'status', - render: (s: string) => , - }, - { - title: '来源', dataIndex: 'source', key: 'source', - render: (t: string) => {t || '—'}, - }, - { title: '对话次数', dataIndex: 'conversation_count', key: 'conversation_count', align: 'center' as const }, - { - title: '最近联系', dataIndex: 'last_contact_at', key: 'last_contact_at', - render: (t: string) => {t ? new Date(t).toLocaleString('zh-CN') : '—'}, - }, - { - title: '操作', key: 'actions', width: 140, - render: (_: unknown, record: Customer) => ( - - - {canDelete && ( - { e?.stopPropagation(); handleDelete(record.id) }}> - - - )} - - ), - }, - ] + 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 */} +
+

客户管理

+
-
- } - placeholder="搜索客户名称、手机号、邮箱" - value={search} - onChange={e => { setSearch(e.target.value); setPage(1) }} - className="w-64" - allowClear - /> - { 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" + />
-
-
{selectedCustomer.name}
-
{selectedCustomer.source || '未知来源'}
-
-
- - {selectedCustomer.phone || '—'} - {selectedCustomer.email || '—'} - - - - {selectedCustomer.conversation_count} - - - {parseTags(selectedCustomer.tags).length === 0 - ? '—' - : parseTags(selectedCustomer.tags).map(t => {t})} - - - -
-
历史会话
-
- {historySessions.length === 0 ? ( -
暂无会话记录
- ) : historySessions.map(s => ( -
-
- 会话 #{s.id} - {s.status} -
-
- {s.last_message || new Date(s.created_at).toLocaleString('zh-CN')} -
-
- ))} +
+ {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="保存" > -
+ - - - - + +
+ + + + + - - - - -