优化客户管理页 UI,对齐效果图布局与详情侧栏

按 P1-03 设计重做工具栏、表格与 360px 内嵌详情面板,并修正布局容器滚动以适配全高页面。
This commit is contained in:
yml2213
2026-07-15 12:55:37 +08:00
parent b26fc58436
commit 6bc9247dd6
2 changed files with 509 additions and 173 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ const AgentLayout = () => {
return (
<div className="flex h-screen overflow-hidden bg-neutral-50">
<AgentSidebar />
<div className="flex-1 min-w-0 h-full overflow-auto">
<div className="flex-1 min-w-0 h-full overflow-hidden">
<Outlet />
</div>
</div>
+508 -172
View File
@@ -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<string, { color: string; text: string }> = {
online: { color: 'green', text: '在线' },
offline: { color: 'default', text: '离线' },
busy: { color: 'orange', text: '忙碌' },
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 tagOptions = ['VIP客户', '新客户', '活跃', '沉默', '企业客户']
const tagColors: Record<string, string> = {
'VIP客户': 'gold', '新客户': 'blue', '活跃': 'green', '沉默': 'default', '企业客户': 'purple',
const filterChips = [
{ key: 'all', label: '全部' },
{ key: 'VIP客户', label: 'VIP' },
{ key: '新客户', label: '新客户' },
{ key: '活跃', label: '活跃' },
{ key: '沉默', label: '沉默' },
]
const tagStyle: Record<string, { bg: string; color: string }> = {
'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<string, string> = {
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 (
<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 [statusFilter, setStatusFilter] = useState<string>()
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 [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) => (
<span className="text-sm font-medium text-neutral-800 cursor-pointer hover:text-blue-500" onClick={() => openDetail(record)}>{text}</span>
),
},
{
title: '联系方式', key: 'contact',
render: (_: unknown, record: Customer) => (
<div className="space-y-0.5">
{record.phone && <div className="text-xs text-neutral-500"><PhoneOutlined className="mr-1" />{record.phone}</div>}
{record.email && <div className="text-xs text-neutral-500"><MailOutlined className="mr-1" />{record.email}</div>}
{!record.phone && !record.email && <span className="text-xs text-neutral-300"></span>}
</div>
),
},
{
title: '标签', dataIndex: 'tags', key: 'tags',
render: (tags: string) => (
<Space size={4} wrap>
{parseTags(tags).map((t: string) => <Tag key={t} color={tagColors[t] || 'default'} className="text-xs">{t}</Tag>)}
</Space>
),
},
{
title: '状态', dataIndex: 'status', key: 'status',
render: (s: string) => <Badge color={statusMap[s]?.color} text={statusMap[s]?.text || s} />,
},
{
title: '来源', dataIndex: 'source', key: 'source',
render: (t: string) => <span className="text-xs text-neutral-500">{t || '—'}</span>,
},
{ title: '对话次数', dataIndex: 'conversation_count', key: 'conversation_count', align: 'center' as const },
{
title: '最近联系', dataIndex: 'last_contact_at', key: 'last_contact_at',
render: (t: string) => <span className="text-xs text-neutral-400">{t ? new Date(t).toLocaleString('zh-CN') : '—'}</span>,
},
{
title: '操作', key: 'actions', width: 140,
render: (_: unknown, record: Customer) => (
<Space size={0}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={e => { e.stopPropagation(); openEdit(record) }}></Button>
{canDelete && (
<Popconfirm title="确认删除该客户?" onConfirm={e => { e?.stopPropagation(); handleDelete(record.id) }}>
<Button type="link" size="small" danger icon={<DeleteOutlined />} onClick={e => e.stopPropagation()}></Button>
</Popconfirm>
)}
</Space>
),
},
]
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 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-neutral-800"></h2>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}></Button>
</div>
<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 gap-3 mb-3 flex-wrap">
<Input
prefix={<SearchOutlined />}
placeholder="搜索客户名称、手机号、邮箱"
value={search}
onChange={e => { setSearch(e.target.value); setPage(1) }}
className="w-64"
allowClear
/>
<Select
placeholder="状态筛选"
value={statusFilter}
onChange={v => { setStatusFilter(v); setPage(1) }}
className="min-w-28"
options={Object.entries(statusMap).map(([k, v]) => ({ value: k, label: v.text }))}
allowClear
/>
</div>
<div className="flex-1 bg-white rounded-lg border border-neutral-200 overflow-hidden">
<Table
dataSource={customers}
columns={columns}
rowKey="id"
size="middle"
loading={loading}
pagination={{ current: page, total, pageSize: 10, showTotal: t => `${t} 个客户`, onChange: p => setPage(p) }}
locale={{ emptyText: <Empty description="暂无客户数据" /> }}
onRow={record => ({ onClick: () => openDetail(record), style: { cursor: 'pointer' } })}
/>
</div>
<Drawer
title="客户详情"
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={420}
extra={
selectedCustomer && (
<Button type="primary" icon={<EditOutlined />} size="small" onClick={() => openEdit(selectedCustomer)}>
</Button>
)
}
>
{selectedCustomer && (
<div className="space-y-5">
<div className="flex items-center gap-3 pb-4 border-b border-neutral-100">
<div className="w-12 h-12 rounded-full bg-blue-100 flex items-center justify-center text-blue-500 text-lg font-semibold">
{selectedCustomer.name[0]}
<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>
<div className="text-base font-medium text-neutral-800">{selectedCustomer.name}</div>
<div className="text-xs text-neutral-400">{selectedCustomer.source || '未知来源'}</div>
</div>
</div>
<Descriptions column={1} size="small" colon={false}>
<Descriptions.Item label="手机号">{selectedCustomer.phone || '—'}</Descriptions.Item>
<Descriptions.Item label="邮箱">{selectedCustomer.email || '—'}</Descriptions.Item>
<Descriptions.Item label="状态">
<Badge color={statusMap[selectedCustomer.status]?.color} text={statusMap[selectedCustomer.status]?.text} />
</Descriptions.Item>
<Descriptions.Item label="对话次数">{selectedCustomer.conversation_count}</Descriptions.Item>
<Descriptions.Item label="标签">
<Space size={4} wrap>
{parseTags(selectedCustomer.tags).length === 0
? '—'
: parseTags(selectedCustomer.tags).map(t => <Tag key={t} color={tagColors[t] || 'default'}>{t}</Tag>)}
</Space>
</Descriptions.Item>
</Descriptions>
<div>
<div className="text-xs font-semibold text-neutral-500 mb-2"></div>
<div className="space-y-2 max-h-64 overflow-auto">
{historySessions.length === 0 ? (
<div className="text-xs text-neutral-400"></div>
) : historySessions.map(s => (
<div key={s.id} className="rounded-lg border border-neutral-100 bg-neutral-50 px-3 py-2">
<div className="flex justify-between text-sm">
<span className="font-medium text-neutral-700"> #{s.id}</span>
<Tag className="text-xs m-0">{s.status}</Tag>
</div>
<div className="text-xs text-neutral-400 mt-1 line-clamp-1">
{s.last_message || new Date(s.created_at).toLocaleString('zh-CN')}
</div>
</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 />}
className="!text-[13px] !h-8 !px-3 !font-medium !text-neutral-600 !border-neutral-200"
onClick={() => message.info('导出功能后续版本提供')}
>
</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} />)}
</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} />)}
</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>
)}
</Drawer>
</div>
<Modal
title={editingId ? '编辑客户' : '新增客户'}
title={editingId ? '编辑客户' : '添加客户'}
open={editOpen}
onCancel={() => setEditOpen(false)}
onOk={() => form.submit()}
confirmLoading={saving}
destroyOnClose
okText="保存"
>
<Form form={form} layout="vertical" className="mt-2" onFinish={handleSave}>
<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} />
</Form.Item>
<Form.Item name="phone" label="手机号" rules={[{ pattern: /^$|^1[3-9]\d{9}$/, message: '手机号格式不正确' }]}>
<Input maxLength={20} placeholder="可选" />
<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="如:网页、微信" />
</Form.Item>
<Form.Item name="status" label="状态">
<Select options={Object.entries(statusMap).map(([k, v]) => ({ value: k, label: v.text }))} />
<Form.Item name="source" label="来源渠道">
<Input maxLength={30} placeholder="如:官网咨询、微信、APP" />
</Form.Item>
<Form.Item name="tags" label="标签">
<Select mode="tags" maxCount={10} options={tagOptions.map(t => ({ value: t, label: t }))} placeholder="选择或输入标签" />