实现P1业务模块:客户管理、对话记录、知识库、数据统计页面
This commit is contained in:
@@ -1,6 +1,182 @@
|
|||||||
const ChatHistory = () => (
|
import { useState } from 'react'
|
||||||
<div className="h-full flex items-center justify-center text-neutral-400 text-center">
|
import { Input, Select, DatePicker, Tag, Empty } from 'antd'
|
||||||
<div><div className="text-lg mb-1">对话记录</div><div className="text-sm">开发中...</div></div>
|
import { SearchOutlined, UserOutlined } from '@ant-design/icons'
|
||||||
</div>
|
import type { Dayjs } from 'dayjs'
|
||||||
)
|
|
||||||
|
interface ChatRecord {
|
||||||
|
id: string
|
||||||
|
visitorName: string
|
||||||
|
avatar?: string
|
||||||
|
lastMessage: string
|
||||||
|
time: string
|
||||||
|
status: 'active' | 'ended' | 'archived'
|
||||||
|
channel: string
|
||||||
|
agent: string
|
||||||
|
satisfaction?: number
|
||||||
|
messages: { sender: string; content: string; time: string; type: 'message' | 'system' | 'action' }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockRecords: ChatRecord[] = [
|
||||||
|
{
|
||||||
|
id: '1', visitorName: '张三', lastMessage: '好的谢谢,问题已解决', time: '10:45', status: 'ended', channel: '网页', agent: '客服小王',
|
||||||
|
satisfaction: 5,
|
||||||
|
messages: [
|
||||||
|
{ sender: '张三', content: '你好,我的订单怎么还没发货?', time: '10:30', type: 'message' },
|
||||||
|
{ sender: '客服小王', content: '您好,请提供一下您的订单号,我帮您查看', time: '10:31', type: 'message' },
|
||||||
|
{ sender: '张三', content: '订单号 AB20260714001', time: '10:32', type: 'message' },
|
||||||
|
{ sender: '系统', content: '客服小王查看了客户资料', time: '10:32', type: 'system' },
|
||||||
|
{ sender: '客服小王', content: '您好,您的订单已经在配送中,预计明天到达', time: '10:35', type: 'message' },
|
||||||
|
{ sender: '张三', content: '好的谢谢,问题已解决', time: '10:45', type: 'message' },
|
||||||
|
{ sender: '系统', content: '客服小王结束会话,原因:已解决', time: '10:45', type: 'action' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2', visitorName: '李四', lastMessage: '这个功能怎么用?', time: '09:20', status: 'ended', channel: '微信', agent: '客服小李',
|
||||||
|
satisfaction: 4,
|
||||||
|
messages: [
|
||||||
|
{ sender: '李四', content: '这个功能怎么用?', time: '09:15', type: 'message' },
|
||||||
|
{ sender: '客服小李', content: '您好,请问您咨询的是哪个功能?', time: '09:16', type: 'message' },
|
||||||
|
{ sender: '李四', content: '就是那个批量导入', time: '09:17', type: 'message' },
|
||||||
|
{ sender: '系统', content: '客服小李使用了知识库条目"批量导入指南"', time: '09:18', type: 'action' },
|
||||||
|
{ sender: '客服小李', content: '批量导入功能在设置→数据管理中,支持 CSV 和 Excel 格式,点击即可上传', time: '09:19', type: 'message' },
|
||||||
|
{ sender: '李四', content: '明白了,谢谢', time: '09:20', type: 'message' },
|
||||||
|
{ sender: '系统', content: '客服小李结束会话,原因:已解决', time: '09:20', type: 'action' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3', visitorName: '王五', lastMessage: '你们的价格比其他家贵很多', time: '08:50', status: 'active', channel: 'APP', agent: '客服小张',
|
||||||
|
messages: [
|
||||||
|
{ sender: '王五', content: '你们的价格比其他家贵很多', time: '08:45', type: 'message' },
|
||||||
|
{ sender: '客服小张', content: '您好,我们的产品在功能完整性和服务支持上有明显优势', time: '08:46', type: 'message' },
|
||||||
|
{ sender: '王五', content: '具体说说看', time: '08:47', type: 'message' },
|
||||||
|
{ sender: '客服小张', content: '我们有7×24小时在线客服、免费知识库搭建、数据报表分析等增值服务', time: '08:48', type: 'message' },
|
||||||
|
{ sender: '系统', content: '客服小张将会话转接给客服主管', time: '08:49', type: 'action' },
|
||||||
|
{ sender: '王五', content: '那还可以', time: '08:50', type: 'message' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4', visitorName: '匿名访客342', lastMessage: '请问可以试用吗', time: '07-11 16:30', status: 'archived', channel: '网页', agent: '客服小王',
|
||||||
|
satisfaction: 3,
|
||||||
|
messages: [
|
||||||
|
{ sender: '匿名访客342', content: '请问可以试用吗', time: '16:25', type: 'message' },
|
||||||
|
{ sender: '客服小王', content: '可以的,我们提供7天免费试用', time: '16:26', type: 'message' },
|
||||||
|
{ sender: '系统', content: '客服小王结束会话,原因:已解决', time: '16:30', type: 'action' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = { active: 'blue', ended: 'green', archived: 'default' }
|
||||||
|
const statusLabels: Record<string, string> = { active: '进行中', ended: '已结束', archived: '已归档' }
|
||||||
|
|
||||||
|
const ChatHistory = () => {
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [channelFilter, setChannelFilter] = useState<string>()
|
||||||
|
const [agentFilter, setAgentFilter] = useState<string>()
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string>()
|
||||||
|
const [dateRange, setDateRange] = useState<[Dayjs | null, Dayjs | null]>([null, null])
|
||||||
|
const [selectedId, setSelectedId] = useState<string>('1')
|
||||||
|
|
||||||
|
const filtered = mockRecords.filter(r => {
|
||||||
|
if (search && !r.visitorName.includes(search)) return false
|
||||||
|
if (channelFilter && r.channel !== channelFilter) return false
|
||||||
|
if (agentFilter && r.agent !== agentFilter) return false
|
||||||
|
if (statusFilter && r.status !== statusFilter) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const selected = mockRecords.find(r => r.id === selectedId)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex">
|
||||||
|
{/* 左侧列表 */}
|
||||||
|
<div className="w-[360px] flex-shrink-0 bg-white border-r border-neutral-200 flex flex-col">
|
||||||
|
<div className="p-3 border-b border-neutral-100 space-y-2">
|
||||||
|
<Input prefix={<SearchOutlined />} placeholder="搜索访客名称..." value={search} onChange={e => setSearch(e.target.value)} allowClear size="small" />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select placeholder="渠道" value={channelFilter} onChange={setChannelFilter} allowClear size="small" className="flex-1" options={['网页', '微信', 'APP', '邮件'].map(v => ({ value: v, label: v }))} />
|
||||||
|
<Select placeholder="状态" value={statusFilter} onChange={setStatusFilter} allowClear size="small" className="flex-1" options={['active', 'ended', 'archived'].map(v => ({ value: v, label: statusLabels[v] }))} />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select placeholder="客服" value={agentFilter} onChange={setAgentFilter} allowClear size="small" className="flex-1" options={['客服小王', '客服小李', '客服小张'].map(v => ({ value: v, label: v }))} />
|
||||||
|
<DatePicker.RangePicker size="small" className="flex-1" value={dateRange as [Dayjs | null, Dayjs | null] | null} onChange={(dates) => setDateRange(dates as [Dayjs | null, Dayjs | null])} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-full"><Empty description="暂无对话记录" /></div>
|
||||||
|
) : (
|
||||||
|
filtered.map(r => (
|
||||||
|
<div
|
||||||
|
key={r.id}
|
||||||
|
className={`px-3 py-3 border-b border-neutral-50 cursor-pointer hover:bg-neutral-50 transition-colors ${selectedId === r.id ? 'bg-blue-50' : ''}`}
|
||||||
|
onClick={() => setSelectedId(r.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-neutral-100 flex items-center justify-center flex-shrink-0">
|
||||||
|
<UserOutlined className="text-neutral-400 text-sm" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-medium text-neutral-800 truncate">{r.visitorName}</div>
|
||||||
|
<div className="text-xs text-neutral-400">{r.channel} · {r.agent}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Tag color={statusColors[r.status]} className="text-xs">{statusLabels[r.status]}</Tag>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-neutral-400 mt-1.5 truncate">{r.lastMessage}</p>
|
||||||
|
<div className="flex items-center justify-between mt-1">
|
||||||
|
<span className="text-xs text-neutral-300">{r.time}</span>
|
||||||
|
{r.satisfaction && <span className="text-xs text-yellow-500">{'★'.repeat(r.satisfaction)}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧详情 */}
|
||||||
|
<div className="flex-1 bg-white overflow-auto">
|
||||||
|
{selected ? (
|
||||||
|
<div className="p-6 max-w-3xl mx-auto">
|
||||||
|
<div className="flex items-center justify-between mb-6 pb-4 border-b border-neutral-100">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-800">{selected.visitorName}</h3>
|
||||||
|
<Tag color={statusColors[selected.status]}>{statusLabels[selected.status]}</Tag>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-neutral-400 mt-1">{selected.channel} · {selected.agent} · {selected.time}</div>
|
||||||
|
</div>
|
||||||
|
{selected.satisfaction && (
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-xl text-yellow-500">{'★'.repeat(selected.satisfaction)}{'☆'.repeat(5 - selected.satisfaction)}</div>
|
||||||
|
<div className="text-xs text-neutral-400">满意度评分</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{selected.messages.map((msg, i) => (
|
||||||
|
<div key={i} className={`flex ${msg.type === 'system' || msg.type === 'action' ? 'justify-center' : msg.sender.startsWith('客服') ? 'justify-start' : 'justify-end'}`}>
|
||||||
|
{msg.type === 'message' ? (
|
||||||
|
<div className={`max-w-[60%] rounded-lg px-3 py-2 text-sm ${msg.sender.startsWith('客服') ? 'bg-neutral-100 text-neutral-700' : 'bg-blue-500 text-white'}`}>
|
||||||
|
{msg.content}
|
||||||
|
<div className={`text-xs mt-1 ${msg.sender.startsWith('客服') ? 'text-neutral-400' : 'text-white/60'}`}>{msg.sender} · {msg.time}</div>
|
||||||
|
</div>
|
||||||
|
) : msg.type === 'system' ? (
|
||||||
|
<span className="text-xs text-neutral-300">{msg.content}</span>
|
||||||
|
) : (
|
||||||
|
<div className="bg-neutral-50 border border-neutral-200 rounded px-3 py-1.5 text-xs text-neutral-500">{msg.content}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-full flex items-center justify-center text-neutral-400">选择一个对话查看详情</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default ChatHistory
|
export default ChatHistory
|
||||||
|
|||||||
@@ -1,6 +1,176 @@
|
|||||||
const Customers = () => (
|
import { useState } from 'react'
|
||||||
<div className="h-full flex items-center justify-center text-neutral-400 text-center">
|
import { Table, Input, Select, Tag, Drawer, Button, Space, Badge, Descriptions, Tabs, Empty } from 'antd'
|
||||||
<div><div className="text-lg mb-1">客户管理</div><div className="text-sm">开发中...</div></div>
|
import { SearchOutlined, PhoneOutlined, MailOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'
|
||||||
</div>
|
|
||||||
)
|
interface Customer {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
phone: string
|
||||||
|
email: string
|
||||||
|
tags: string[]
|
||||||
|
status: 'online' | 'offline' | 'busy'
|
||||||
|
source: string
|
||||||
|
conversationCount: number
|
||||||
|
satisfaction: number
|
||||||
|
pending: number
|
||||||
|
lastContact: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockCustomers: Customer[] = [
|
||||||
|
{ id: '1', name: '张三', phone: '138****8888', email: 'zhang@example.com', tags: ['VIP客户', '新客户'], status: 'online', source: '网页', conversationCount: 12, satisfaction: 4.8, pending: 0, lastContact: '2026-07-14 10:32' },
|
||||||
|
{ id: '2', name: '李四', phone: '139****7777', email: 'li@example.com', tags: ['活跃'], status: 'offline', source: '微信', conversationCount: 8, satisfaction: 4.5, pending: 2, lastContact: '2026-07-14 09:15' },
|
||||||
|
{ id: '3', name: '王五', phone: '137****6666', email: 'wang@example.com', tags: ['企业客户', 'VIP客户'], status: 'busy', source: 'APP', conversationCount: 25, satisfaction: 4.2, pending: 1, lastContact: '2026-07-14 11:00' },
|
||||||
|
{ id: '4', name: '赵六科技', phone: '136****5555', email: 'zhao@tech.com', tags: ['企业客户'], status: 'online', source: '网页', conversationCount: 6, satisfaction: 4.9, pending: 0, lastContact: '2026-07-13 16:45' },
|
||||||
|
{ id: '5', name: '钱七', phone: '135****4444', email: '', tags: ['沉默'], status: 'offline', source: '邮件', conversationCount: 3, satisfaction: 3.5, pending: 0, lastContact: '2026-07-10 14:20' },
|
||||||
|
{ id: '6', name: '孙八', phone: '134****3333', email: 'sun@example.com', tags: ['新客户', '活跃'], status: 'online', source: '网页', conversationCount: 2, satisfaction: 0, pending: 3, lastContact: '2026-07-14 10:30' },
|
||||||
|
{ id: '7', name: '周九', phone: '133****2222', email: 'zhou@example.com', tags: ['VIP客户'], status: 'busy', source: '微信', conversationCount: 18, satisfaction: 4.7, pending: 0, lastContact: '2026-07-14 09:50' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const statusMap: Record<string, { color: string; text: string }> = {
|
||||||
|
online: { color: 'green', text: '在线' },
|
||||||
|
offline: { color: 'default', text: '离线' },
|
||||||
|
busy: { color: 'orange', text: '忙碌' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagColors: Record<string, string> = {
|
||||||
|
'VIP客户': 'gold',
|
||||||
|
'新客户': 'blue',
|
||||||
|
'活跃': 'green',
|
||||||
|
'沉默': 'default',
|
||||||
|
'企业客户': 'purple',
|
||||||
|
}
|
||||||
|
|
||||||
|
const Customers = () => {
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [tagFilter, setTagFilter] = useState<string[]>([])
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string[]>([])
|
||||||
|
const [sourceFilter, setSourceFilter] = useState<string[]>([])
|
||||||
|
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null)
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||||
|
|
||||||
|
const filtered = mockCustomers.filter(c => {
|
||||||
|
if (search && !c.name.includes(search) && !c.phone.includes(search) && !c.email.includes(search)) return false
|
||||||
|
if (tagFilter.length > 0 && !tagFilter.some(t => c.tags.includes(t))) return false
|
||||||
|
if (statusFilter.length > 0 && !statusFilter.includes(c.status)) return false
|
||||||
|
if (sourceFilter.length > 0 && !sourceFilter.includes(c.source)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
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>{tags.map(t => <Tag key={t} color={tagColors[t] || 'default'} className="text-xs">{t}</Tag>)}</Space>
|
||||||
|
)},
|
||||||
|
{ title: '状态', dataIndex: 'status', key: 'status', render: (status: string) => <Badge color={statusMap[status]?.color} text={statusMap[status]?.text} /> },
|
||||||
|
{ title: '来源', dataIndex: 'source', key: 'source', render: (text: string) => <span className="text-xs text-neutral-500">{text}</span> },
|
||||||
|
{ title: '对话次数', dataIndex: 'conversationCount', key: 'conversationCount', align: 'center' as const },
|
||||||
|
{ title: '满意度', dataIndex: 'satisfaction', key: 'satisfaction', align: 'center' as const, render: (v: number) => v > 0 ? <span className="text-green-600 font-medium">{v.toFixed(1)}</span> : <span className="text-neutral-300">-</span> },
|
||||||
|
{ title: '待处理', dataIndex: 'pending', key: 'pending', align: 'center' as const, render: (v: number) => v > 0 ? <Badge count={v} size="small" /> : <span className="text-neutral-300">0</span> },
|
||||||
|
{ title: '最近联系', dataIndex: 'lastContact', key: 'lastContact', render: (text: string) => <span className="text-xs text-neutral-400">{text}</span> },
|
||||||
|
]
|
||||||
|
|
||||||
|
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 />}>新增客户</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 mb-3 flex-wrap">
|
||||||
|
<Input prefix={<SearchOutlined />} placeholder="搜索客户名称、手机号、邮箱" value={search} onChange={e => setSearch(e.target.value)} className="w-64" allowClear />
|
||||||
|
<Select mode="multiple" placeholder="标签筛选" value={tagFilter} onChange={setTagFilter} className="min-w-32" options={['VIP客户', '新客户', '活跃', '沉默', '企业客户'].map(v => ({ value: v, label: v }))} allowClear />
|
||||||
|
<Select mode="multiple" placeholder="状态筛选" value={statusFilter} onChange={setStatusFilter} className="min-w-28" options={['online', 'offline', 'busy'].map(v => ({ value: v, label: statusMap[v].text }))} allowClear />
|
||||||
|
<Select mode="multiple" placeholder="来源渠道" value={sourceFilter} onChange={setSourceFilter} className="min-w-28" options={['网页', '微信', 'APP', '邮件'].map(v => ({ value: v, label: v }))} allowClear />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 bg-white rounded-lg border border-neutral-200 overflow-hidden">
|
||||||
|
<Table
|
||||||
|
dataSource={filtered}
|
||||||
|
columns={columns}
|
||||||
|
rowKey="id"
|
||||||
|
size="middle"
|
||||||
|
pagination={{ pageSize: 10, showTotal: total => `共 ${total} 个客户` }}
|
||||||
|
locale={{ emptyText: <Empty description="暂无客户数据" /> }}
|
||||||
|
onRow={record => ({ onClick: () => { setSelectedCustomer(record); setDrawerOpen(true) }, style: { cursor: 'pointer' } })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
title="客户详情"
|
||||||
|
open={drawerOpen}
|
||||||
|
onClose={() => setDrawerOpen(false)}
|
||||||
|
width={400}
|
||||||
|
extra={<Button type="primary" icon={<EditOutlined />} size="small">编辑</Button>}
|
||||||
|
>
|
||||||
|
{selectedCustomer && (
|
||||||
|
<Tabs
|
||||||
|
defaultActiveKey="profile"
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'profile',
|
||||||
|
label: '基本信息',
|
||||||
|
children: (
|
||||||
|
<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>
|
||||||
|
<div className="text-base font-medium text-neutral-800">{selectedCustomer.name}</div>
|
||||||
|
<div className="text-xs text-neutral-400">{selectedCustomer.source} · 最近 {selectedCustomer.lastContact}</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>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-neutral-400 mb-2">标签</div>
|
||||||
|
<Space size={4} wrap>
|
||||||
|
{selectedCustomer.tags.map(t => <Tag key={t} color={tagColors[t] || 'default'} closable>{t}</Tag>)}
|
||||||
|
<Tag className="border-dashed cursor-pointer"><PlusOutlined /> 添加</Tag>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-neutral-400 mb-2">内部备注</div>
|
||||||
|
<div className="text-sm text-neutral-600 bg-neutral-50 rounded p-3">
|
||||||
|
该客户为重要 VIP,需优先响应。上次咨询售后服务,已解决。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'history',
|
||||||
|
label: '对话历史',
|
||||||
|
children: (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[{ date: '2026-07-14 10:30', topic: '订单物流咨询', status: '已结束', satisfaction: 5 },
|
||||||
|
{ date: '2026-07-13 14:20', topic: '退换货流程', status: '已结束', satisfaction: 4 },
|
||||||
|
{ date: '2026-07-12 09:15', topic: '产品使用问题', status: '已结束', satisfaction: 5 },
|
||||||
|
].map((h, i) => (
|
||||||
|
<div key={i} className="p-3 border border-neutral-100 rounded-lg">
|
||||||
|
<div className="text-sm font-medium text-neutral-700">{h.topic}</div>
|
||||||
|
<div className="flex justify-between mt-1.5 text-xs text-neutral-400">
|
||||||
|
<span>{h.date}</span>
|
||||||
|
<span>{'★'.repeat(h.satisfaction)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default Customers
|
export default Customers
|
||||||
|
|||||||
@@ -1,6 +1,158 @@
|
|||||||
const Knowledge = () => (
|
import { useState } from 'react'
|
||||||
<div className="h-full flex items-center justify-center text-neutral-400 text-center">
|
import { Tree, Table, Input, Button, Modal, Form, Select, Tag, Space, Empty, Progress, Popconfirm } from 'antd'
|
||||||
<div><div className="text-lg mb-1">知识库</div><div className="text-sm">开发中...</div></div>
|
import { SearchOutlined, PlusOutlined, EditOutlined, DeleteOutlined, FileTextOutlined } from '@ant-design/icons'
|
||||||
</div>
|
|
||||||
)
|
interface Category {
|
||||||
|
key: string
|
||||||
|
title: string
|
||||||
|
children?: Category[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KnowledgeEntry {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
category: string
|
||||||
|
content: string
|
||||||
|
status: 'published' | 'draft'
|
||||||
|
usageCount: number
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const categories: Category[] = [
|
||||||
|
{ key: 'product', title: '产品常见问题', children: [
|
||||||
|
{ key: 'product-feature', title: '功能介绍' },
|
||||||
|
{ key: 'product-compare', title: '版本对比' },
|
||||||
|
]},
|
||||||
|
{ key: 'after-sale', title: '售后服务', children: [
|
||||||
|
{ key: 'refund', title: '退换货政策' },
|
||||||
|
{ key: 'warranty', title: '保修说明' },
|
||||||
|
]},
|
||||||
|
{ key: 'support', title: '技术支持' },
|
||||||
|
{ key: 'policy', title: '政策条款' },
|
||||||
|
{ key: 'quick-reply', title: '快捷回复模板' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const mockEntries: KnowledgeEntry[] = [
|
||||||
|
{ id: '1', title: '如何修改登录密码', category: 'product', content: '登录后在右上角头像→个人设置→修改密码中输入旧密码和新密码即可完成修改。', status: 'published', usageCount: 156, updatedAt: '2026-07-14' },
|
||||||
|
{ id: '2', title: '支持哪些支付方式', category: 'product-feature', content: '目前支持微信支付、支付宝、银行转账三种方式。企业客户支持对公转账。', status: 'published', usageCount: 98, updatedAt: '2026-07-13' },
|
||||||
|
{ id: '3', title: '免费版和专业版区别', category: 'product-compare', content: '免费版提供基础的客服功能,专业版支持更多渠道接入、高级报表和API接口。', status: 'published', usageCount: 72, updatedAt: '2026-07-12' },
|
||||||
|
{ id: '4', title: '退货流程说明', category: 'refund', content: '在订单页面点击申请退货→填写退货原因→等待审核→寄回商品→退款到账,全程约3-5个工作日。', status: 'published', usageCount: 45, updatedAt: '2026-07-11' },
|
||||||
|
{ id: '5', title: 'API 接口文档', category: 'support', content: '开发者文档请访问 docs.example.com/api,提供 REST API 和 Webhook 两种接入方式。', status: 'draft', usageCount: 0, updatedAt: '2026-07-10' },
|
||||||
|
{ id: '6', title: '欢迎语模板', category: 'quick-reply', content: '您好!欢迎来到客服云,请问有什么可以帮您的?', status: 'published', usageCount: 230, updatedAt: '2026-07-09' },
|
||||||
|
{ id: '7', title: '结束语模板', category: 'quick-reply', content: '感谢您的咨询,如有其他问题随时联系我们,祝您生活愉快!', status: 'published', usageCount: 189, updatedAt: '2026-07-09' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const Knowledge = () => {
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<string>('product')
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
const [editingEntry, setEditingEntry] = useState<KnowledgeEntry | null>(null)
|
||||||
|
const [form] = Form.useForm()
|
||||||
|
|
||||||
|
const filtered = mockEntries.filter(e => {
|
||||||
|
if (selectedCategory && e.category !== selectedCategory) return false
|
||||||
|
if (search && !e.title.includes(search) && !e.content.includes(search)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const openNew = () => {
|
||||||
|
setEditingEntry(null)
|
||||||
|
form.resetFields()
|
||||||
|
form.setFieldsValue({ category: selectedCategory, status: 'draft' })
|
||||||
|
setModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEdit = (entry: KnowledgeEntry) => {
|
||||||
|
setEditingEntry(entry)
|
||||||
|
form.setFieldsValue(entry)
|
||||||
|
setModalOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '标题', dataIndex: 'title', key: 'title', render: (text: string) => <span className="text-sm font-medium text-neutral-800">{text}</span> },
|
||||||
|
{ title: '状态', dataIndex: 'status', key: 'status', width: 80, render: (status: string) => (
|
||||||
|
<Tag color={status === 'published' ? 'green' : 'default'}>{status === 'published' ? '已发布' : '草稿'}</Tag>
|
||||||
|
)},
|
||||||
|
{ title: '使用频率', dataIndex: 'usageCount', key: 'usageCount', width: 200, render: (count: number) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Progress percent={Math.min(count / 2, 100)} size="small" showInfo={false} strokeColor="#2563eb" className="flex-1 max-w-32" />
|
||||||
|
<span className="text-xs text-neutral-400">{count}次</span>
|
||||||
|
</div>
|
||||||
|
)},
|
||||||
|
{ title: '更新时间', dataIndex: 'updatedAt', key: 'updatedAt', width: 120, render: (text: string) => <span className="text-xs text-neutral-400">{text}</span> },
|
||||||
|
{ title: '操作', key: 'actions', width: 120, render: (_: unknown, record: KnowledgeEntry) => (
|
||||||
|
<Space size={0}>
|
||||||
|
<Button type="link" size="small" icon={<EditOutlined />} onClick={(e) => { e.stopPropagation(); openEdit(record) }}>编辑</Button>
|
||||||
|
<Popconfirm title="确定删除?" onConfirm={(e) => e?.stopPropagation()} onCancel={(e) => e?.stopPropagation()}>
|
||||||
|
<Button type="link" size="small" danger icon={<DeleteOutlined />} onClick={(e) => e.stopPropagation()} />
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
)},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex">
|
||||||
|
{/* 左侧分类树 */}
|
||||||
|
<div className="w-[240px] flex-shrink-0 bg-white border-r border-neutral-200 p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<FileTextOutlined className="text-blue-500" />
|
||||||
|
<span className="text-sm font-semibold text-neutral-700">知识分类</span>
|
||||||
|
</div>
|
||||||
|
<Tree
|
||||||
|
treeData={categories as any}
|
||||||
|
defaultExpandAll
|
||||||
|
selectedKeys={[selectedCategory]}
|
||||||
|
onSelect={keys => { if (keys.length > 0) setSelectedCategory(keys[0] as string) }}
|
||||||
|
blockNode
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧内容 */}
|
||||||
|
<div className="flex-1 flex flex-col p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-800">知识库</h2>
|
||||||
|
<Input prefix={<SearchOutlined />} placeholder="搜索标题或内容..." value={search} onChange={e => setSearch(e.target.value)} className="w-56" size="small" allowClear />
|
||||||
|
</div>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={openNew}>新建条目</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 bg-white rounded-lg border border-neutral-200 overflow-hidden">
|
||||||
|
<Table
|
||||||
|
dataSource={filtered}
|
||||||
|
columns={columns}
|
||||||
|
rowKey="id"
|
||||||
|
size="middle"
|
||||||
|
pagination={{ pageSize: 10 }}
|
||||||
|
locale={{ emptyText: <Empty description="暂无知识条目" /> }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editingEntry ? '编辑知识条目' : '新建知识条目'}
|
||||||
|
open={modalOpen}
|
||||||
|
onCancel={() => setModalOpen(false)}
|
||||||
|
onOk={() => form.submit()}
|
||||||
|
width={640}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" onFinish={_values => setModalOpen(false)} className="mt-4">
|
||||||
|
<Form.Item name="title" label="标题" rules={[{ required: true, message: '请输入标题' }]}>
|
||||||
|
<Input placeholder="条目标题,2-100字符" maxLength={100} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="category" label="分类" rules={[{ required: true }]}>
|
||||||
|
<Select options={categories.map(c => ({ value: c.key, label: c.title }))} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="content" label="内容" rules={[{ required: true, message: '请输入内容' }]}>
|
||||||
|
<Input.TextArea rows={6} placeholder="条目内容,支持纯文本" maxLength={5000} showCount />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="status" label="状态">
|
||||||
|
<Select options={[{ value: 'published', label: '发布' }, { value: 'draft', label: '草稿' }]} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default Knowledge
|
export default Knowledge
|
||||||
|
|||||||
@@ -1,6 +1,140 @@
|
|||||||
const Statistics = () => (
|
import { useState } from 'react'
|
||||||
<div className="h-full flex items-center justify-center text-neutral-400 text-center">
|
import { Card, Segmented, Row, Col } from 'antd'
|
||||||
<div><div className="text-lg mb-1">数据统计</div><div className="text-sm">开发中...</div></div>
|
import { ArrowUpOutlined, ArrowDownOutlined, ClockCircleOutlined, SmileOutlined, CheckCircleOutlined, MessageOutlined } from '@ant-design/icons'
|
||||||
</div>
|
import { Column, Line, Pie, Bar } from '@ant-design/charts'
|
||||||
)
|
|
||||||
|
const kpiData = [
|
||||||
|
{ label: '总会话量', value: '12,580', change: 12.5, icon: <MessageOutlined />, color: '#2563eb' },
|
||||||
|
{ label: '平均响应时长', value: '32s', change: -8.3, icon: <ClockCircleOutlined />, color: '#16a34a' },
|
||||||
|
{ label: '客户满意度', value: '4.8/5', change: 2.1, icon: <SmileOutlined />, color: '#d97706' },
|
||||||
|
{ label: '首次解决率', value: '86%', change: 5.7, icon: <CheckCircleOutlined />, color: '#0891b2' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const sessionTrendData = [
|
||||||
|
{ date: '07-08', count: 420 }, { date: '07-09', count: 380 }, { date: '07-10', count: 450 },
|
||||||
|
{ date: '07-11', count: 520 }, { date: '07-12', count: 490 }, { date: '07-13', count: 550 },
|
||||||
|
{ date: '07-14', count: 610 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const responseDistribution = [
|
||||||
|
{ range: '0-10s', count: 320 }, { range: '10-30s', count: 450 }, { range: '30-60s', count: 280 },
|
||||||
|
{ range: '1-3min', count: 180 }, { range: '>3min', count: 50 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const channelData = [
|
||||||
|
{ type: '网页', value: 45 }, { type: '微信', value: 28 }, { type: 'APP', value: 18 },
|
||||||
|
{ type: '电话工单', value: 6 }, { type: '邮件', value: 3 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const agentPerformance = [
|
||||||
|
{ name: '客服小王', conversations: 420, avgResponse: 28, satisfaction: 4.9 },
|
||||||
|
{ name: '客服小李', conversations: 380, avgResponse: 35, satisfaction: 4.7 },
|
||||||
|
{ name: '客服小张', conversations: 350, avgResponse: 42, satisfaction: 4.5 },
|
||||||
|
{ name: '客服小赵', conversations: 290, avgResponse: 30, satisfaction: 4.8 },
|
||||||
|
{ name: '客服小刘', conversations: 220, avgResponse: 55, satisfaction: 4.2 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const Statistics = () => {
|
||||||
|
const [timeRange, setTimeRange] = useState<string>('week')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-auto p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-800">数据统计</h2>
|
||||||
|
<Segmented
|
||||||
|
value={timeRange}
|
||||||
|
onChange={v => setTimeRange(v as string)}
|
||||||
|
options={[
|
||||||
|
{ value: 'today', label: '今日' },
|
||||||
|
{ value: 'week', label: '本周' },
|
||||||
|
{ value: 'month', label: '本月' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI 卡片 */}
|
||||||
|
<Row gutter={[16, 16]} className="mb-6">
|
||||||
|
{kpiData.map((kpi, i) => (
|
||||||
|
<Col key={i} xs={24} sm={12} lg={6}>
|
||||||
|
<Card className="!rounded-lg" bordered={false}>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-sm text-neutral-400">{kpi.label}</span>
|
||||||
|
<span className="text-lg" style={{ color: kpi.color }}>{kpi.icon}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold text-neutral-800 mb-1">{kpi.value}</div>
|
||||||
|
<div className={`text-xs flex items-center gap-1 ${kpi.change >= 0 ? 'text-green-600' : 'text-red-500'}`}>
|
||||||
|
{kpi.change >= 0 ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
|
||||||
|
<span>{Math.abs(kpi.change)}% 较上期</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* 图表区 */}
|
||||||
|
<Row gutter={[16, 16]}>
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<Card title="会话量趋势" className="!rounded-lg" bordered={false}>
|
||||||
|
<Line
|
||||||
|
data={sessionTrendData}
|
||||||
|
xField="date"
|
||||||
|
yField="count"
|
||||||
|
smooth
|
||||||
|
height={260}
|
||||||
|
color="#2563eb"
|
||||||
|
point={{ size: 3 }}
|
||||||
|
tooltip={{ channel: 'y' }}
|
||||||
|
axis={{ y: { grid: true, gridStroke: '#f1f5f9' } }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<Card title="响应时长分布" className="!rounded-lg" bordered={false}>
|
||||||
|
<Column
|
||||||
|
data={responseDistribution}
|
||||||
|
xField="range"
|
||||||
|
yField="count"
|
||||||
|
height={260}
|
||||||
|
color="#0891b2"
|
||||||
|
tooltip={{ channel: 'y' }}
|
||||||
|
axis={{ y: { grid: true, gridStroke: '#f1f5f9' } }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<Card title="渠道来源占比" className="!rounded-lg" bordered={false}>
|
||||||
|
<Pie
|
||||||
|
data={channelData}
|
||||||
|
angleField="value"
|
||||||
|
colorField="type"
|
||||||
|
height={260}
|
||||||
|
radius={0.8}
|
||||||
|
innerRadius={0.5}
|
||||||
|
label={{ text: 'type', position: 'outside' }}
|
||||||
|
legend={{ color: { position: 'bottom' } }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
|
<Card title="客服绩效排行" className="!rounded-lg" bordered={false}>
|
||||||
|
<Bar
|
||||||
|
data={agentPerformance}
|
||||||
|
xField="conversations"
|
||||||
|
yField="name"
|
||||||
|
height={260}
|
||||||
|
color="#2563eb"
|
||||||
|
tooltip={{ items: [
|
||||||
|
{ channel: 'conversations', name: '接待量' },
|
||||||
|
{ channel: 'avgResponse', name: '平均响应(s)' },
|
||||||
|
{ channel: 'satisfaction', name: '满意度' },
|
||||||
|
]}}
|
||||||
|
axis={{ x: { grid: true, gridStroke: '#f1f5f9' } }}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default Statistics
|
export default Statistics
|
||||||
|
|||||||
Reference in New Issue
Block a user