修复会话安全与实时消息

This commit is contained in:
yml2213
2026-07-14 15:06:01 +08:00
parent 064af29d3b
commit 8cff2a5824
29 changed files with 1961 additions and 546 deletions
+2 -2
View File
@@ -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 {
+24 -2
View File
@@ -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>}
+52 -25
View File
@@ -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">
+88 -112
View File
@@ -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>
)
}