实现 P1 客户/知识库/对话记录与渠道设置
- 客户管理接通创建、编辑、删除与详情历史会话 - 知识库接通分类/条目 CRUD,按角色控制写权限 - 对话记录增强筛选、客户名、消息与操作时间线 - 新增租户渠道 API,系统设置渠道管理可启用与复制嵌入代码
This commit is contained in:
@@ -1,7 +1,14 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Table, Input, Select, Tag, Drawer, Button, Space, Badge, Descriptions, Empty, message } from 'antd'
|
||||
import { SearchOutlined, PhoneOutlined, MailOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import { getCustomers, type Customer } from '@/services/api'
|
||||
import {
|
||||
Table, Input, Select, Tag, Drawer, Button, Space, Badge, Descriptions, Empty,
|
||||
message, Modal, Form, Popconfirm,
|
||||
} from 'antd'
|
||||
import { SearchOutlined, PhoneOutlined, MailOutlined, EditOutlined, PlusOutlined, DeleteOutlined } 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: '在线' },
|
||||
@@ -9,19 +16,36 @@ const statusMap: Record<string, { color: string; text: string }> = {
|
||||
busy: { color: 'orange', text: '忙碌' },
|
||||
}
|
||||
|
||||
const tagOptions = ['VIP客户', '新客户', '活跃', '沉默', '企业客户']
|
||||
const tagColors: Record<string, string> = {
|
||||
'VIP客户': 'gold', '新客户': 'blue', '活跃': 'green', '沉默': 'default', '企业客户': 'purple',
|
||||
}
|
||||
|
||||
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) : []
|
||||
}
|
||||
}
|
||||
|
||||
const Customers = () => {
|
||||
const { user } = useAuth()
|
||||
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 [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string[]>([])
|
||||
const [statusFilter, setStatusFilter] = useState<string>()
|
||||
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 [editOpen, setEditOpen] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [form] = Form.useForm()
|
||||
|
||||
useEffect(() => {
|
||||
loadCustomers()
|
||||
@@ -30,7 +54,7 @@ const Customers = () => {
|
||||
const loadCustomers = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await getCustomers({ search, status: statusFilter[0], page })
|
||||
const res = await getCustomers({ search, status: statusFilter, page, pageSize: 10 })
|
||||
setCustomers(res.list)
|
||||
setTotal(res.total)
|
||||
} catch {
|
||||
@@ -40,39 +64,164 @@ const Customers = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const parseTags = (tagsStr: string): string[] => {
|
||||
try { return JSON.parse(tagsStr) } catch { return [] }
|
||||
const openDetail = async (record: Customer) => {
|
||||
setSelectedCustomer(record)
|
||||
setDrawerOpen(true)
|
||||
setHistorySessions([])
|
||||
try {
|
||||
const res = await getCustomer(record.id)
|
||||
setSelectedCustomer(res.data.customer)
|
||||
setHistorySessions(Array.isArray(res.data.sessions) ? res.data.sessions : [])
|
||||
} catch {
|
||||
// keep list snapshot
|
||||
}
|
||||
}
|
||||
|
||||
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 (drawerOpen) 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) {
|
||||
setDrawerOpen(false)
|
||||
setSelectedCustomer(null)
|
||||
}
|
||||
await loadCustomers()
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
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={() => { setSelectedCustomer(record); setDrawerOpen(true) }}>{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>}
|
||||
</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} /> },
|
||||
{ title: '来源', dataIndex: 'source', key: 'source', render: (t: string) => <span className="text-xs text-neutral-500">{t}</span> },
|
||||
{
|
||||
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 || '-'}</span> },
|
||||
{
|
||||
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>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
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={() => message.info('新建客户')}>新增客户</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>新增客户</Button>
|
||||
</div>
|
||||
|
||||
<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 mode="multiple" placeholder="状态筛选" value={statusFilter} onChange={v => { setStatusFilter(v); setPage(1) }} className="min-w-28" options={['online', 'offline', 'busy'].map(v => ({ value: v, label: statusMap[v].text }))} allowClear />
|
||||
<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">
|
||||
@@ -84,28 +233,101 @@ const Customers = () => {
|
||||
loading={loading}
|
||||
pagination={{ current: page, total, pageSize: 10, showTotal: t => `共 ${t} 个客户`, onChange: p => setPage(p) }}
|
||||
locale={{ emptyText: <Empty description="暂无客户数据" /> }}
|
||||
onRow={record => ({ onClick: () => { setSelectedCustomer(record); setDrawerOpen(true) }, style: { cursor: 'pointer' } })}
|
||||
onRow={record => ({ onClick: () => openDetail(record), style: { cursor: 'pointer' } })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Drawer title="客户详情" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={400} extra={<Button type="primary" icon={<EditOutlined />} size="small">编辑</Button>}>
|
||||
<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>
|
||||
<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>
|
||||
<div>
|
||||
<div className="text-base font-medium text-neutral-800">{selectedCustomer.name}</div>
|
||||
<div className="text-xs text-neutral-400">{selectedCustomer.source}</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.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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
title={editingId ? '编辑客户' : '新增客户'}
|
||||
open={editOpen}
|
||||
onCancel={() => setEditOpen(false)}
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={saving}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" className="mt-2" 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="可选" />
|
||||
</Form.Item>
|
||||
<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>
|
||||
<Form.Item name="tags" label="标签">
|
||||
<Select mode="tags" maxCount={10} options={tagOptions.map(t => ({ value: t, label: t }))} placeholder="选择或输入标签" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user