实现 P1 客户/知识库/对话记录与渠道设置

- 客户管理接通创建、编辑、删除与详情历史会话
- 知识库接通分类/条目 CRUD,按角色控制写权限
- 对话记录增强筛选、客户名、消息与操作时间线
- 新增租户渠道 API,系统设置渠道管理可启用与复制嵌入代码
This commit is contained in:
yml2213
2026-07-15 11:18:01 +08:00
parent 9485c28abd
commit 65014d80de
8 changed files with 1232 additions and 247 deletions
+254 -32
View File
@@ -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>
)
}