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.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) => (
-
- } onClick={e => { e.stopPropagation(); openEdit(record) }}>编辑
- {canDelete && (
- { e?.stopPropagation(); handleDelete(record.id) }}>
- } onClick={e => e.stopPropagation()}>删除
-
- )}
-
- ),
- },
- ]
+ 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 (
-
-
-
客户管理
- } onClick={openCreate}>新增客户
-
+
+ {/* 顶栏标题 — 对齐设计稿 header */}
+
-
- }
- placeholder="搜索客户名称、手机号、邮箱"
- value={search}
- onChange={e => { setSearch(e.target.value); setPage(1) }}
- className="w-64"
- allowClear
- />
-
-
-
-
`共 ${t} 个客户`, onChange: p => setPage(p) }}
- locale={{ emptyText: }}
- onRow={record => ({ onClick: () => openDetail(record), style: { cursor: 'pointer' } })}
- />
-
-
- setDrawerOpen(false)}
- width={420}
- extra={
- selectedCustomer && (
- } size="small" onClick={() => openEdit(selectedCustomer)}>
- 编辑
-
- )
- }
- >
- {selectedCustomer && (
-
-
-
- {selectedCustomer.name[0]}
+
+ {/* 主内容区 */}
+
+ {/* 工具栏:搜索 + 筛选 + 操作 同一行 */}
+
+
+
+
+ { 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 (
+
+ )
+ })}
+
+
+
+ }
+ className="!text-[13px] !h-8 !px-3 !font-medium !text-neutral-600 !border-neutral-200"
+ onClick={() => message.info('导出功能后续版本提供')}
+ >
+ 导出
+
+ }
+ className="!text-[13px] !h-8 !px-3 !font-medium !bg-[#2563eb]"
+ onClick={openCreate}
+ >
+ 添加客户
+
+
+ {/* 表格 + 分页 */}
+
+
+ {loading ? (
+
+ ) : displayed.length === 0 ? (
+
+ ) : (
+
+
+
+
+ {['客户', '手机号', '邮箱', '来源渠道', '标签', '最后对话', '状态', '操作'].map((h, i) => (
+ |
+ {h}
+ |
+ ))}
+
+
+
+ {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'
+ }`}
+ >
+
+
+
+
+ {(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="保存"
>
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-