对接对话记录和运营概览API,完成全部页面前后端对接

This commit is contained in:
yml2213
2026-07-14 11:34:35 +08:00
parent e4628fee7b
commit 280dd48f3f
2 changed files with 70 additions and 211 deletions
+32 -77
View File
@@ -1,46 +1,32 @@
import { Card, Table, Tag } from 'antd'
import { ArrowUpOutlined, TeamOutlined, UserOutlined, DollarOutlined, SafetyCertificateOutlined } from '@ant-design/icons'
import { Line, Pie } from '@ant-design/charts'
const kpiData = [
{ label: '租户总数', value: '156', change: 8.3, icon: <TeamOutlined />, color: '#2563eb' },
{ label: '活跃租户', value: '138', change: 5.1, icon: <UserOutlined />, color: '#16a34a' },
{ label: '平台月收入', value: '¥89,400', change: 12.7, icon: <DollarOutlined />, color: '#d97706' },
{ label: '系统可用率', value: '99.95%', change: 0.02, icon: <SafetyCertificateOutlined />, color: '#0891b2' },
]
const trendData = [
{ date: '07-01', count: 142 }, { date: '07-03', count: 145 }, { date: '07-05', count: 148 },
{ date: '07-07', count: 150 }, { date: '07-09', count: 152 }, { date: '07-11', count: 154 },
{ date: '07-13', count: 156 },
]
const planDistribution = [
{ type: '基础版', value: 68 }, { type: '专业版', value: 52 }, { type: '企业版', value: 36 },
]
const operations = [
{ time: '2026-07-14 10:30', action: '开通新租户', target: '赵六科技', operator: '管理员A' },
{ time: '2026-07-14 09:15', action: '租户续费', target: '李四商贸', operator: '管理员B' },
{ time: '2026-07-13 16:20', action: '套餐升级', target: '王五集团', operator: '管理员A' },
{ time: '2026-07-13 14:00', action: '租户暂停', target: '孙八信息', operator: '管理员B' },
{ time: '2026-07-13 10:45', action: '开通新租户', target: '周九科技', operator: '管理员A' },
]
const actionColors: Record<string, string> = {
'开通新租户': 'green',
'租户续费': 'blue',
'套餐升级': 'orange',
'租户暂停': 'red',
}
import { useState, useEffect } from 'react'
import { Card, Table, Tag, Spin } from 'antd'
import { ArrowUpOutlined, TeamOutlined, UserOutlined, SafetyCertificateOutlined } from '@ant-design/icons'
import { getTenants, getAdminStats } from '@/services/api'
const AdminDashboard = () => {
const [stats, setStats] = useState<any>(null)
const [tenants, setTenants] = useState<any[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
Promise.all([getAdminStats(), getTenants({ page: 1 })]).then(([statsRes, tenantsRes]) => {
setStats(statsRes.data)
setTenants(tenantsRes.list)
}).catch(() => {}).finally(() => setLoading(false))
}, [])
if (loading) return <div className="h-full flex items-center justify-center"><Spin size="large" /></div>
const kpiData = [
{ label: '租户总数', value: stats?.tenant_total || 0, icon: <TeamOutlined />, color: '#2563eb' },
{ label: '活跃租户', value: stats?.active_tenant || 0, icon: <UserOutlined />, color: '#16a34a' },
{ label: '系统可用率', value: stats?.system_uptime || '99.95%', icon: <SafetyCertificateOutlined />, color: '#0891b2' },
]
return (
<div>
<h2 className="text-lg font-semibold text-neutral-800 mb-5"></h2>
{/* KPI */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
{kpiData.map((k, i) => (
<Card key={i} className="!rounded-lg" bordered={false}>
<div className="flex items-center justify-between mb-3">
@@ -48,53 +34,22 @@ const AdminDashboard = () => {
<span className="text-lg" style={{ color: k.color }}>{k.icon}</span>
</div>
<div className="text-2xl font-bold text-neutral-800 mb-1">{k.value}</div>
<div className="text-xs text-green-600 flex items-center gap-1">
<ArrowUpOutlined /> {k.change}%
</div>
<div className="text-xs text-green-600 flex items-center gap-1"><ArrowUpOutlined /> </div>
</Card>
))}
</div>
{/* 图表 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
<Card title="租户增长趋势(近30天)" className="!rounded-lg" bordered={false}>
<Line
data={trendData}
xField="date"
yField="count"
smooth
height={240}
color="#2563eb"
point={{ size: 3 }}
axis={{ y: { grid: true, gridStroke: '#f1f5f9' } }}
/>
</Card>
<Card title="套餐分布" className="!rounded-lg" bordered={false}>
<Pie
data={planDistribution}
angleField="value"
colorField="type"
height={240}
radius={0.8}
innerRadius={0.6}
label={{ text: 'type', position: 'outside' }}
legend={{ color: { position: 'bottom' } }}
/>
</Card>
</div>
{/* 最近操作 */}
<Card title="最近操作记录" className="!rounded-lg" bordered={false}>
<Table
dataSource={operations}
rowKey="time"
dataSource={tenants.slice(0, 5)}
rowKey="id"
pagination={false}
size="middle"
columns={[
{ title: '时间', dataIndex: 'time', key: 'time', render: (t: string) => <span className="text-sm text-neutral-500">{t}</span> },
{ title: '操作', dataIndex: 'action', key: 'action', render: (a: string) => <Tag color={actionColors[a]}>{a}</Tag> },
{ title: '目标', dataIndex: 'target', key: 'target' },
{ title: '操作人', dataIndex: 'operator', key: 'operator', render: (o: string) => <span className="text-neutral-500">{o}</span> },
{ title: '租户', dataIndex: 'name', key: 'name' },
{ title: '状态', dataIndex: 'status', key: 'status', render: (s: string) => (
<Tag color={s === 'normal' ? 'green' : s === 'expiring' ? 'orange' : 'red'}>{s}</Tag>
)},
{ title: '到期日期', dataIndex: 'expire_at', key: 'expire_at', render: (t: string) => t ? new Date(t).toLocaleDateString() : '-' },
]}
/>
</Card>
+38 -134
View File
@@ -1,179 +1,83 @@
import { useState } from 'react'
import { Input, Select, DatePicker, Tag, Empty } from 'antd'
import { useState, useEffect } from 'react'
import { Input, Select, Tag, Empty, Spin } 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' },
],
},
]
import { getSessions, type Session as SessionType } from '@/services/api'
const statusColors: Record<string, string> = { active: 'blue', ended: 'green', archived: 'default' }
const statusLabels: Record<string, string> = { active: '进行中', ended: '已结束', archived: '已归档' }
const statusLabels: Record<string, string> = { active: '进行中', ended: '已结束', waiting: '等待中' }
const ChatHistory = () => {
const [sessions, setSessions] = useState<SessionType[]>([])
const [loading, setLoading] = useState(true)
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 [selectedId, setSelectedId] = useState<number | null>(null)
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
})
useEffect(() => { loadSessions() }, [statusFilter])
const selected = mockRecords.find(r => r.id === selectedId)
const loadSessions = async () => {
setLoading(true)
try {
const res = await getSessions({ status: statusFilter })
setSessions(res.list)
if (res.list.length > 0 && !selectedId) setSelectedId(res.list[0].id)
} catch { setSessions([]) } finally { setLoading(false) }
}
const selected = sessions.find(s => s.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>
<Input prefix={<SearchOutlined />} placeholder="搜索访客..." value={search} onChange={e => setSearch(e.target.value)} allowClear size="small" />
<Select placeholder="状态" value={statusFilter} onChange={setStatusFilter} allowClear size="small" className="w-full" options={Object.entries(statusLabels).map(([k, v]) => ({ value: k, label: v }))} />
</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)}
>
{loading ? <div className="flex justify-center py-10"><Spin /></div> :
sessions.filter(s => !search || s.status.includes(search) || String(s.customer_id).includes(search)).map(s => (
<div key={s.id} className={`px-3 py-3 border-b border-neutral-50 cursor-pointer hover:bg-neutral-50 transition-colors ${selectedId === s.id ? 'bg-blue-50' : ''}`}
onClick={() => setSelectedId(s.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 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><div className="text-sm font-medium text-neutral-800">{s.customer_id}</div><div className="text-xs text-neutral-400">ID: {s.id}</div></div>
</div>
<Tag color={statusColors[r.status]} className="text-xs">{statusLabels[r.status]}</Tag>
<Tag color={statusColors[s.status]} className="text-xs">{statusLabels[s.status] || s.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>}
<span className="text-xs text-neutral-300">{new Date(s.created_at).toLocaleString('zh-CN')}</span>
{s.satisfaction_score && <span className="text-xs text-yellow-500">{'★'.repeat(s.satisfaction_score)}</span>}
</div>
</div>
))
)}
))}
{!loading && sessions.length === 0 && <div className="flex items-center justify-center h-full"><Empty description="暂无对话记录" /></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>
<h3 className="text-lg font-semibold text-neutral-800"> {selected.id}</h3>
<Tag color={statusColors[selected.status]}>{statusLabels[selected.status] || selected.status}</Tag>
</div>
<div className="text-sm text-neutral-400 mt-1">{selected.channel} · {selected.agent} · {selected.time}</div>
<div className="text-sm text-neutral-400 mt-1">ID: {selected.customer_id} · {new Date(selected.created_at).toLocaleString('zh-CN')}</div>
</div>
{selected.satisfaction && (
{selected.satisfaction_score && (
<div className="text-right">
<div className="text-xl text-yellow-500">{'★'.repeat(selected.satisfaction)}{'☆'.repeat(5 - selected.satisfaction)}</div>
<div className="text-xl text-yellow-500">{'★'.repeat(selected.satisfaction_score)}{'☆'.repeat(5 - selected.satisfaction_score)}</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 className="text-sm text-neutral-400 text-center"> WebSocket </div>
</div>
</div>
) : (
<div className="h-full flex items-center justify-center text-neutral-400"></div>
)}
) : <div className="h-full flex items-center justify-center text-neutral-400"></div>}
</div>
</div>
)