实现P1业务模块:客户管理、对话记录、知识库、数据统计页面

This commit is contained in:
yml2213
2026-07-14 11:13:02 +08:00
parent 2492daae3b
commit a1ba0475ac
4 changed files with 652 additions and 20 deletions
+181 -5
View File
@@ -1,6 +1,182 @@
const ChatHistory = () => (
<div className="h-full flex items-center justify-center text-neutral-400 text-center">
<div><div className="text-lg mb-1"></div><div className="text-sm">...</div></div>
</div>
)
import { useState } from 'react'
import { Input, Select, DatePicker, Tag, Empty } from 'antd'
import { SearchOutlined, UserOutlined } from '@ant-design/icons'
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
+175 -5
View File
@@ -1,6 +1,176 @@
const Customers = () => (
<div className="h-full flex items-center justify-center text-neutral-400 text-center">
<div><div className="text-lg mb-1"></div><div className="text-sm">...</div></div>
</div>
)
import { useState } from 'react'
import { Table, Input, Select, Tag, Drawer, Button, Space, Badge, Descriptions, Tabs, Empty } from 'antd'
import { SearchOutlined, PhoneOutlined, MailOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'
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
+157 -5
View File
@@ -1,6 +1,158 @@
const Knowledge = () => (
<div className="h-full flex items-center justify-center text-neutral-400 text-center">
<div><div className="text-lg mb-1"></div><div className="text-sm">...</div></div>
</div>
)
import { useState } from 'react'
import { Tree, Table, Input, Button, Modal, Form, Select, Tag, Space, Empty, Progress, Popconfirm } from 'antd'
import { SearchOutlined, PlusOutlined, EditOutlined, DeleteOutlined, FileTextOutlined } from '@ant-design/icons'
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
+139 -5
View File
@@ -1,6 +1,140 @@
const Statistics = () => (
<div className="h-full flex items-center justify-center text-neutral-400 text-center">
<div><div className="text-lg mb-1"></div><div className="text-sm">...</div></div>
</div>
)
import { useState } from 'react'
import { Card, Segmented, Row, Col } from 'antd'
import { ArrowUpOutlined, ArrowDownOutlined, ClockCircleOutlined, SmileOutlined, CheckCircleOutlined, MessageOutlined } from '@ant-design/icons'
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