修复会话安全与实时消息
This commit is contained in:
@@ -13,9 +13,9 @@ const Login = () => {
|
||||
const onFinish = async (values: { username: string; password: string }) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await login(values.username, values.password)
|
||||
const user = await login(values.username, values.password)
|
||||
message.success('登录成功')
|
||||
navigate('/agent/dashboard', { replace: true })
|
||||
navigate(user.role === 'platform_admin' ? '/admin/dashboard' : '/agent/dashboard', { replace: true })
|
||||
} catch {
|
||||
message.error('用户名或密码错误')
|
||||
} finally {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Input, Select, Tag, Empty, Spin } from 'antd'
|
||||
import { SearchOutlined, UserOutlined } from '@ant-design/icons'
|
||||
import { getSessions, type Session as SessionType } from '@/services/api'
|
||||
import { getSession, 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: '已结束', waiting: '等待中' }
|
||||
@@ -12,6 +12,8 @@ const ChatHistory = () => {
|
||||
const [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>()
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null)
|
||||
const [messages, setMessages] = useState<{ id: number; sender_type: string; content: string; sent_at: string }[]>([])
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
|
||||
useEffect(() => { loadSessions() }, [statusFilter])
|
||||
|
||||
@@ -26,6 +28,18 @@ const ChatHistory = () => {
|
||||
|
||||
const selected = sessions.find(s => s.id === selectedId)
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedId) {
|
||||
setMessages([])
|
||||
return
|
||||
}
|
||||
setDetailLoading(true)
|
||||
getSession(selectedId).then(res => {
|
||||
const detail = res.data as { messages?: { id: number; sender_type: string; content: string; sent_at: string }[] }
|
||||
setMessages(detail.messages || [])
|
||||
}).catch(() => setMessages([])).finally(() => setDetailLoading(false))
|
||||
}, [selectedId])
|
||||
|
||||
return (
|
||||
<div className="h-full flex">
|
||||
<div className="w-[360px] flex-shrink-0 bg-white border-r border-neutral-200 flex flex-col">
|
||||
@@ -74,7 +88,15 @@ const ChatHistory = () => {
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-neutral-400 text-center">消息内容通过 WebSocket 实时传输</div>
|
||||
{detailLoading ? <div className="flex justify-center py-10"><Spin /></div> : messages.length === 0 ? <Empty description="暂无消息记录" /> : messages.map(message => (
|
||||
<div key={message.id} className={`flex ${message.sender_type === 'agent' ? 'justify-start' : 'justify-end'}`}>
|
||||
<div className={`max-w-[70%] rounded-lg px-3 py-2 text-sm ${message.sender_type === 'agent' ? 'bg-neutral-100 text-neutral-700' : 'bg-blue-500 text-white'}`}>
|
||||
<div className="text-xs mb-1 opacity-70">{message.sender_type === 'agent' ? '客服' : '访客'}</div>
|
||||
<div>{message.content}</div>
|
||||
<div className="text-xs mt-1 opacity-60">{new Date(message.sent_at).toLocaleString('zh-CN')}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : <div className="h-full flex items-center justify-center text-neutral-400">选择一个对话查看详情</div>}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { Input, Spin, message as antMsg } from 'antd'
|
||||
import { SearchOutlined, StarFilled } from '@ant-design/icons'
|
||||
import { useAuth } from '@/stores/auth'
|
||||
import { getSessions, getSession, getCustomers, type Session as SessionType, type Customer } from '@/services/api'
|
||||
import { endSession, getSessions, getSession, getCustomers, type Session as SessionType, type Customer } from '@/services/api'
|
||||
|
||||
const priorityColors: Record<string, string> = { urgent: '#dc2626', normal: '#d97706', waiting: '#d97706' }
|
||||
const priorityLabels: Record<string, string> = { urgent: '紧急', waiting: '等待中', active: '进行中', normal: '进行中' }
|
||||
@@ -21,21 +21,21 @@ const Dashboard = () => {
|
||||
const initialLoad = useRef(true)
|
||||
const chatEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => { loadAll() }, [])
|
||||
|
||||
const loadAll = async () => {
|
||||
const loadAll = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [sRes, cRes] = await Promise.all([
|
||||
getSessions(),
|
||||
getCustomers({ page: 1 }),
|
||||
])
|
||||
setSessions(sRes.list)
|
||||
const sessionList = Array.isArray(sRes.list) ? sRes.list : []
|
||||
const customerList = Array.isArray(cRes.list) ? cRes.list : []
|
||||
setSessions(sessionList)
|
||||
const map: Record<number, Customer> = {}
|
||||
cRes.list.forEach(c => { map[c.id] = c })
|
||||
customerList.forEach(c => { map[c.id] = c })
|
||||
setCustomers(map)
|
||||
if (sRes.list.length > 0 && initialLoad.current) {
|
||||
setSelectedId(sRes.list[0].id)
|
||||
if (sessionList.length > 0 && initialLoad.current) {
|
||||
setSelectedId(sessionList[0].id)
|
||||
initialLoad.current = false
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -43,20 +43,16 @@ const Dashboard = () => {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId) loadDetail(selectedId)
|
||||
}, [selectedId])
|
||||
|
||||
const loadDetail = async (id: number) => {
|
||||
const loadDetail = useCallback(async (id: number) => {
|
||||
setDetailLoading(true)
|
||||
try {
|
||||
const res = await getSession(id)
|
||||
const msgs: any = res.data as any
|
||||
setDetail({
|
||||
messages: (msgs.messages || []).map((m: any) => ({
|
||||
sender: m.sender_type === 'agent' ? '客服' : getCustomerName(id),
|
||||
sender: m.sender_type === 'agent' ? '客服' : '访客',
|
||||
content: m.content,
|
||||
time: new Date(m.sent_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
|
||||
})),
|
||||
@@ -67,14 +63,33 @@ const Dashboard = () => {
|
||||
setDetailLoading(false)
|
||||
}
|
||||
setTimeout(() => chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 200)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const getCustomerName = (sessionId: number): string => {
|
||||
const s = sessions.find(s => s.id === sessionId)
|
||||
if (!s) return '访客'
|
||||
const c = customers[s.customer_id]
|
||||
return c ? c.name : `客户${s.customer_id}`
|
||||
}
|
||||
useEffect(() => { loadAll() }, [loadAll])
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.token) return
|
||||
const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const socket = new WebSocket(`${scheme}//${window.location.host}/api/ws`, ['kefu-v1', user.token])
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data)
|
||||
if (payload.type === 'message' && payload.session_id === selectedId) {
|
||||
loadDetail(payload.session_id)
|
||||
}
|
||||
if (payload.type === 'session_created' || payload.type === 'session_updated') {
|
||||
loadAll()
|
||||
}
|
||||
} catch {
|
||||
// 忽略格式错误的实时消息
|
||||
}
|
||||
}
|
||||
return () => socket.close()
|
||||
}, [user?.token, selectedId, loadAll, loadDetail])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId) loadDetail(selectedId)
|
||||
}, [selectedId, loadDetail])
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!messageInput.trim() || !selectedId || sending) return
|
||||
@@ -110,6 +125,18 @@ const Dashboard = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleEnd = async () => {
|
||||
if (!selectedId || sending) return
|
||||
try {
|
||||
await endSession(selectedId, 'resolved')
|
||||
antMsg.success('会话已结束')
|
||||
await loadAll()
|
||||
await loadDetail(selectedId)
|
||||
} catch {
|
||||
antMsg.error('结束会话失败')
|
||||
}
|
||||
}
|
||||
|
||||
const selected = sessions.find(s => s.id === selectedId)
|
||||
const selectedCustomer = selected ? customers[selected.customer_id] : null
|
||||
|
||||
@@ -170,8 +197,8 @@ const Dashboard = () => {
|
||||
<span className="text-xs text-neutral-400">{statusLabels[selected.status]}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="text-xs text-neutral-400 cursor-pointer hover:text-neutral-600">转接</span>
|
||||
<span className="text-xs text-neutral-400 cursor-pointer hover:text-red-500">结束</span>
|
||||
<span className="text-xs text-neutral-400 cursor-not-allowed">转接</span>
|
||||
<span className="text-xs text-neutral-400 cursor-pointer hover:text-red-500" onClick={handleEnd}>结束</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-4 flex flex-col gap-3">
|
||||
|
||||
@@ -1,41 +1,55 @@
|
||||
import { useState } from 'react'
|
||||
import { Card, Segmented, Row, Col } from 'antd'
|
||||
import { ArrowUpOutlined, ArrowDownOutlined, ClockCircleOutlined, SmileOutlined, CheckCircleOutlined, MessageOutlined } from '@ant-design/icons'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Card, Segmented, Row, Col, Spin } from 'antd'
|
||||
import { 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 },
|
||||
]
|
||||
import { getAgentPerformance, getChannelDistribution, getKPIs, getResponseDistribution, getSessionTrend, type StatisticsKpis } from '@/services/api'
|
||||
|
||||
const Statistics = () => {
|
||||
const [timeRange, setTimeRange] = useState<string>('week')
|
||||
const [timeRange, setTimeRange] = useState<'today' | 'week' | 'month'>('week')
|
||||
const [kpis, setKpis] = useState<StatisticsKpis | null>(null)
|
||||
const [sessionTrendData, setSessionTrendData] = useState<{ date: string; count: number }[]>([])
|
||||
const [responseDistribution, setResponseDistribution] = useState<{ range: string; count: number }[]>([])
|
||||
const [channelData, setChannelData] = useState<{ type: string; value: number }[]>([])
|
||||
const [agentPerformance, setAgentPerformance] = useState<{ name: string; conversations: number; avgResponse: number; satisfaction: number }[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const period = timeRange === 'week' ? 'day' : timeRange
|
||||
const [kpiRes, trendRes, distributionRes, channelRes, performanceRes] = await Promise.all([
|
||||
getKPIs(), getSessionTrend(period), getResponseDistribution(), getChannelDistribution(), getAgentPerformance(),
|
||||
])
|
||||
setKpis(kpiRes.data)
|
||||
setSessionTrendData(trendRes.data)
|
||||
setResponseDistribution(distributionRes.data)
|
||||
setChannelData(channelRes.data)
|
||||
setAgentPerformance(performanceRes.data.map(item => ({
|
||||
name: item.name,
|
||||
conversations: item.conversations,
|
||||
avgResponse: item.avg_response,
|
||||
satisfaction: item.satisfaction,
|
||||
})))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load().catch(() => {
|
||||
setKpis(null)
|
||||
setSessionTrendData([])
|
||||
setResponseDistribution([])
|
||||
setChannelData([])
|
||||
setAgentPerformance([])
|
||||
})
|
||||
}, [timeRange])
|
||||
|
||||
const kpiData = [
|
||||
{ label: '总会话量', value: String(kpis?.total_sessions ?? 0), icon: <MessageOutlined />, color: '#2563eb' },
|
||||
{ label: '平均响应时长', value: `${Math.round(kpis?.avg_response_time ?? 0)}s`, icon: <ClockCircleOutlined />, color: '#16a34a' },
|
||||
{ label: '客户满意度', value: `${(kpis?.satisfaction_avg ?? 0).toFixed(1)}/5`, icon: <SmileOutlined />, color: '#d97706' },
|
||||
{ label: '首次解决率', value: `${(kpis?.first_resolve_rate ?? 0).toFixed(1)}%`, icon: <CheckCircleOutlined />, color: '#0891b2' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
@@ -43,7 +57,7 @@ const Statistics = () => {
|
||||
<h2 className="text-lg font-semibold text-neutral-800">数据统计</h2>
|
||||
<Segmented
|
||||
value={timeRange}
|
||||
onChange={v => setTimeRange(v as string)}
|
||||
onChange={v => setTimeRange(v as 'today' | 'week' | 'month')}
|
||||
options={[
|
||||
{ value: 'today', label: '今日' },
|
||||
{ value: 'week', label: '本周' },
|
||||
@@ -52,87 +66,49 @@ const Statistics = () => {
|
||||
/>
|
||||
</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>
|
||||
{loading && !kpis ? <div className="h-64 flex items-center justify-center"><Spin size="large" /></div> : <>
|
||||
<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} loading={loading}>
|
||||
<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 text-neutral-400">基于已记录会话实时计算</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>
|
||||
))}
|
||||
</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: [
|
||||
<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>
|
||||
]}} axis={{ x: { grid: true, gridStroke: '#f1f5f9' } }} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user