修复会话安全与实时消息
This commit is contained in:
@@ -7,4 +7,32 @@ const RequireAuth = () => {
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
export const RequirePlatformAdmin = () => {
|
||||
const { user } = useAuth()
|
||||
if (!user) return <Navigate to="/login" replace />
|
||||
if (user.role !== 'platform_admin') return <Navigate to="/agent/dashboard" replace />
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
export const RequireStaff = () => {
|
||||
const { user } = useAuth()
|
||||
if (!user) return <Navigate to="/login" replace />
|
||||
if (user.role === 'platform_admin') return <Navigate to="/admin/dashboard" replace />
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
export const RequireSupervisor = () => {
|
||||
const { user } = useAuth()
|
||||
if (!user) return <Navigate to="/login" replace />
|
||||
if (user.role !== 'admin' && user.role !== 'supervisor') return <Navigate to="/agent/dashboard" replace />
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
export const RequireTenantAdmin = () => {
|
||||
const { user } = useAuth()
|
||||
if (!user) return <Navigate to="/login" replace />
|
||||
if (user.role !== 'admin') return <Navigate to="/agent/dashboard" replace />
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
export default RequireAuth
|
||||
|
||||
@@ -21,8 +21,13 @@ const AgentSidebar = () => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { user, logout } = useAuth()
|
||||
const visibleMenuItems = menuItems.filter(item => {
|
||||
if (item.key === '/agent/statistics') return user?.role === 'admin' || user?.role === 'supervisor'
|
||||
if (item.key === '/agent/settings') return user?.role === 'admin'
|
||||
return true
|
||||
})
|
||||
|
||||
const selectedKey = menuItems.find(item => location.pathname.startsWith(item.key))?.key || '/agent/dashboard'
|
||||
const selectedKey = visibleMenuItems.find(item => location.pathname.startsWith(item.key))?.key || '/agent/dashboard'
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
@@ -38,7 +43,7 @@ const AgentSidebar = () => {
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[selectedKey]}
|
||||
items={menuItems}
|
||||
items={visibleMenuItems}
|
||||
onClick={({ key }) => navigate(key)}
|
||||
className="border-e-0 mt-2 flex-1"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+31
-15
@@ -1,7 +1,7 @@
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { Navigate, createBrowserRouter } from 'react-router-dom'
|
||||
import { Spin } from 'antd'
|
||||
import RequireAuth from '@/components/RequireAuth'
|
||||
import RequireAuth, { RequirePlatformAdmin, RequireStaff, RequireSupervisor, RequireTenantAdmin } from '@/components/RequireAuth'
|
||||
|
||||
const AgentLayout = lazy(() => import('@/components/layout/AgentLayout'))
|
||||
const AdminLayout = lazy(() => import('@/components/layout/AdminLayout'))
|
||||
@@ -37,27 +37,43 @@ export const router = createBrowserRouter([
|
||||
children: [
|
||||
{
|
||||
path: 'admin',
|
||||
element: <Lazy><AdminLayout /></Lazy>,
|
||||
element: <RequirePlatformAdmin />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/admin/dashboard" replace /> },
|
||||
{ path: 'dashboard', element: <Lazy><AdminDashboard /></Lazy> },
|
||||
{ path: 'tenants', element: <Lazy><Tenants /></Lazy> },
|
||||
{ path: 'plans', element: <Lazy><Plans /></Lazy> },
|
||||
{ path: 'ops', element: <Lazy><Ops /></Lazy> },
|
||||
{
|
||||
element: <Lazy><AdminLayout /></Lazy>,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/admin/dashboard" replace /> },
|
||||
{ path: 'dashboard', element: <Lazy><AdminDashboard /></Lazy> },
|
||||
{ path: 'tenants', element: <Lazy><Tenants /></Lazy> },
|
||||
{ path: 'plans', element: <Lazy><Plans /></Lazy> },
|
||||
{ path: 'ops', element: <Lazy><Ops /></Lazy> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{ index: true, element: <Navigate to="/agent/dashboard" replace /> },
|
||||
{
|
||||
path: 'agent',
|
||||
element: <Lazy><AgentLayout /></Lazy>,
|
||||
element: <RequireStaff />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/agent/dashboard" replace /> },
|
||||
{ path: 'dashboard', element: <Lazy><Dashboard /></Lazy> },
|
||||
{ path: 'chat-history', element: <Lazy><ChatHistory /></Lazy> },
|
||||
{ path: 'customers', element: <Lazy><Customers /></Lazy> },
|
||||
{ path: 'knowledge', element: <Lazy><Knowledge /></Lazy> },
|
||||
{ path: 'statistics', element: <Lazy><Statistics /></Lazy> },
|
||||
{ path: 'settings', element: <Lazy><Settings /></Lazy> },
|
||||
{
|
||||
element: <Lazy><AgentLayout /></Lazy>,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/agent/dashboard" replace /> },
|
||||
{ path: 'dashboard', element: <Lazy><Dashboard /></Lazy> },
|
||||
{ path: 'chat-history', element: <Lazy><ChatHistory /></Lazy> },
|
||||
{ path: 'customers', element: <Lazy><Customers /></Lazy> },
|
||||
{ path: 'knowledge', element: <Lazy><Knowledge /></Lazy> },
|
||||
{
|
||||
element: <RequireSupervisor />,
|
||||
children: [{ path: 'statistics', element: <Lazy><Statistics /></Lazy> }],
|
||||
},
|
||||
{
|
||||
element: <RequireTenantAdmin />,
|
||||
children: [{ path: 'settings', element: <Lazy><Settings /></Lazy> }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
+13
-2
@@ -23,6 +23,14 @@ export interface Tenant {
|
||||
contact_name: string; contact_phone: string; contact_email: string
|
||||
}
|
||||
|
||||
export interface StatisticsKpis {
|
||||
total_sessions: number
|
||||
avg_response_time: number
|
||||
satisfaction_avg: number
|
||||
first_resolve_rate: number
|
||||
total_messages: number
|
||||
}
|
||||
|
||||
// Auth
|
||||
export const login = (params: LoginParams) => post<LoginResult>('/login', params)
|
||||
|
||||
@@ -58,8 +66,11 @@ export const getKnowledgeEntries = (params?: { category_id?: string; search?: st
|
||||
}
|
||||
|
||||
// Statistics
|
||||
export const getKPIs = () => get<Record<string, number>>('/statistics/kpi')
|
||||
export const getSessionTrend = () => get<{ date: string; count: number }[]>('/statistics/trend')
|
||||
export const getKPIs = () => get<StatisticsKpis>('/statistics/kpi')
|
||||
export const getSessionTrend = (period: 'today' | 'day' | 'month' = 'day') => get<{ date: string; count: number }[]>(`/statistics/trend?period=${period}`)
|
||||
export const getResponseDistribution = () => get<{ range: string; count: number }[]>('/statistics/response-distribution')
|
||||
export const getChannelDistribution = () => get<{ type: string; value: number }[]>('/statistics/channels')
|
||||
export const getAgentPerformance = () => get<{ name: string; conversations: number; avg_response: number; satisfaction: number }[]>('/statistics/performance')
|
||||
|
||||
// Admin
|
||||
export const getTenants = (params?: { search?: string; status?: string; page?: number }) => {
|
||||
|
||||
@@ -3,15 +3,15 @@ import { setToken } from '@/services/request'
|
||||
import { login as loginApi, type LoginResult } from '@/services/api'
|
||||
|
||||
interface AuthState {
|
||||
user: LoginResult | null
|
||||
loading: boolean
|
||||
login: (username: string, password: string) => Promise<void>
|
||||
user: LoginResult | null
|
||||
loading: boolean
|
||||
login: (username: string, password: string) => Promise<LoginResult>
|
||||
logout: () => void
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthState>({
|
||||
user: null, loading: false,
|
||||
login: async () => {}, logout: () => {},
|
||||
user: null, loading: false,
|
||||
login: async () => { throw new Error('认证上下文未初始化') }, logout: () => {},
|
||||
})
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
@@ -31,9 +31,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
try {
|
||||
const res = await loginApi({ username, password })
|
||||
const u = res.data
|
||||
setToken(u.token)
|
||||
setUser(u)
|
||||
localStorage.setItem('auth_user', JSON.stringify(u))
|
||||
setToken(u.token)
|
||||
setUser(u)
|
||||
localStorage.setItem('auth_user', JSON.stringify(u))
|
||||
return u
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { CloseOutlined, MessageOutlined, SmileOutlined, SendOutlined, StarFilled } from '@ant-design/icons'
|
||||
|
||||
interface Message {
|
||||
@@ -10,6 +10,7 @@ interface Message {
|
||||
|
||||
const quickQuestions = ['产品功能介绍', '价格咨询', '售后服务', '合作咨询']
|
||||
const STORAGE_KEY = 'kefu_widget_session'
|
||||
const VISITOR_TOKEN_KEY = 'kefu_widget_visitor_token'
|
||||
|
||||
const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
@@ -17,6 +18,7 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
return saved ? Number(saved) : null
|
||||
})
|
||||
const [visitorToken, setVisitorToken] = useState<string>(() => localStorage.getItem(VISITOR_TOKEN_KEY) || '')
|
||||
const [messages, setMessages] = useState<Message[]>(() => {
|
||||
const saved = localStorage.getItem(STORAGE_KEY + '_msgs')
|
||||
return saved ? JSON.parse(saved) : []
|
||||
@@ -25,31 +27,19 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||
const [sending, setSending] = useState(false)
|
||||
const [showRating, setShowRating] = useState(false)
|
||||
const [rated, setRated] = useState(false)
|
||||
const [sessionEnded, setSessionEnded] = useState(false)
|
||||
const [ratingText, setRatingText] = useState('')
|
||||
const pollRef = useRef<number | null>(null)
|
||||
const initRef = useRef(false)
|
||||
|
||||
const initSession = async () => {
|
||||
if (initRef.current && sessionId) return
|
||||
initRef.current = true
|
||||
try {
|
||||
const res = await fetch(`/api/widget/init?channel_key=WK_8a3f2e&visitor_name=客服云访客`, { method: 'POST' })
|
||||
const json = await res.json()
|
||||
if (json.code === 0) {
|
||||
const sid = json.data.session_id
|
||||
setSessionId(sid)
|
||||
localStorage.setItem(STORAGE_KEY, String(sid))
|
||||
await loadMessages(sid)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Init failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const loadMessages = async (sid?: number) => {
|
||||
const loadMessages = useCallback(async (sid?: number, token?: string) => {
|
||||
const s = sid || sessionId
|
||||
if (!s) return
|
||||
const visitorCredential = token || visitorToken || localStorage.getItem(VISITOR_TOKEN_KEY) || ''
|
||||
if (!s || !visitorCredential) return
|
||||
try {
|
||||
const res = await fetch(`/api/widget/messages?session_id=${s}`)
|
||||
const res = await fetch(`/api/widget/messages?session_id=${s}`, {
|
||||
headers: { 'X-Visitor-Token': visitorCredential },
|
||||
})
|
||||
const json = await res.json()
|
||||
if (json.code === 0 && json.data && json.data.length > 0) {
|
||||
const msgs: Message[] = json.data.map((m: any) => ({
|
||||
@@ -62,20 +52,70 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||
localStorage.setItem(STORAGE_KEY + '_msgs', JSON.stringify(msgs))
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}, [sessionId, visitorToken])
|
||||
|
||||
const initSession = useCallback(async () => {
|
||||
if (sessionId && visitorToken) {
|
||||
await loadMessages(sessionId, visitorToken)
|
||||
return
|
||||
}
|
||||
if (initRef.current) return
|
||||
initRef.current = true
|
||||
try {
|
||||
const res = await fetch(`/api/widget/init?channel_key=WK_8a3f2e&visitor_name=客服云访客`, { method: 'POST' })
|
||||
const json = await res.json()
|
||||
if (json.code === 0) {
|
||||
const sid = json.data.session_id
|
||||
const token = json.data.visitor_token
|
||||
setSessionId(sid)
|
||||
setVisitorToken(token)
|
||||
localStorage.setItem(STORAGE_KEY, String(sid))
|
||||
localStorage.setItem(VISITOR_TOKEN_KEY, token)
|
||||
await loadMessages(sid, token)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Init failed:', e)
|
||||
}
|
||||
}, [sessionId, visitorToken, loadMessages])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !initRef.current) {
|
||||
if (open) {
|
||||
initSession()
|
||||
}
|
||||
}, [open, sessionId])
|
||||
}, [open, initSession])
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionId && open) {
|
||||
if (sessionId && visitorToken && open) {
|
||||
pollRef.current = window.setInterval(() => loadMessages(), 3000)
|
||||
return () => { if (pollRef.current) clearInterval(pollRef.current) }
|
||||
}
|
||||
}, [sessionId, open])
|
||||
}, [sessionId, visitorToken, open, loadMessages])
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId || !visitorToken || !open) return
|
||||
const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const socket = new WebSocket(
|
||||
`${scheme}//${window.location.host}/api/widget/ws?session_id=${sessionId}`,
|
||||
['kefu-visitor-v1', visitorToken],
|
||||
)
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data)
|
||||
if (payload.session_id === sessionId && (payload.type === 'message' || payload.type === 'session_updated')) {
|
||||
if (payload.type === 'session_updated' && payload.data?.status === 'ended') {
|
||||
setSessionEnded(true)
|
||||
setShowRating(true)
|
||||
}
|
||||
loadMessages(sessionId, visitorToken)
|
||||
}
|
||||
} catch {
|
||||
// 忽略格式错误的实时消息
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
socket.close()
|
||||
}
|
||||
}, [sessionId, visitorToken, open, loadMessages])
|
||||
|
||||
const sendMessage = async (text: string) => {
|
||||
if (!text.trim() || sending) return
|
||||
@@ -89,11 +129,11 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||
}
|
||||
setMessages(prev => [...prev, localMsg])
|
||||
|
||||
if (sessionId) {
|
||||
if (sessionId && visitorToken) {
|
||||
try {
|
||||
await fetch('/api/widget/message', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', 'X-Visitor-Token': visitorToken },
|
||||
body: JSON.stringify({ session_id: sessionId, content, type: 'text' }),
|
||||
})
|
||||
loadMessages(sessionId)
|
||||
@@ -104,13 +144,30 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||
|
||||
const handleOpen = () => {
|
||||
setOpen(true)
|
||||
setShowRating(false)
|
||||
setRated(false)
|
||||
setShowRating(sessionEnded && !rated)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false)
|
||||
if (!rated) setShowRating(true)
|
||||
if (sessionEnded && !rated) setShowRating(true)
|
||||
}
|
||||
|
||||
const submitRating = async (score: number) => {
|
||||
if (!sessionId || !visitorToken || rated) return
|
||||
try {
|
||||
const res = await fetch('/api/widget/rating', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Visitor-Token': visitorToken },
|
||||
body: JSON.stringify({ session_id: sessionId, score, text: ratingText }),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (json.code === 0) {
|
||||
setRated(true)
|
||||
setShowRating(false)
|
||||
}
|
||||
} catch {
|
||||
// 评价失败时保留弹窗,允许访客稍后重试
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -173,9 +230,9 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') sendMessage(input) }}
|
||||
disabled={sending || !sessionId}
|
||||
disabled={sending || !sessionId || sessionEnded}
|
||||
/>
|
||||
<SendOutlined className={`cursor-pointer ${sessionId ? 'text-blue-500 hover:text-blue-600' : 'text-neutral-300'}`} onClick={() => sendMessage(input)} />
|
||||
<SendOutlined className={`cursor-pointer ${sessionId && !sessionEnded ? 'text-blue-500 hover:text-blue-600' : 'text-neutral-300'}`} onClick={() => sendMessage(input)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -187,9 +244,17 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||
<div className="flex justify-center gap-1.5 mb-4">
|
||||
{[1, 2, 3, 4, 5].map(star => (
|
||||
<StarFilled key={star} className="text-2xl cursor-pointer text-neutral-200 hover:text-yellow-400 transition-colors"
|
||||
onClick={() => { setRated(true); setShowRating(false) }} />
|
||||
onClick={() => submitRating(star)} />
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
className="w-full rounded-lg border border-neutral-200 p-2 text-sm outline-none focus:border-blue-400 mb-3"
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
value={ratingText}
|
||||
onChange={e => setRatingText(e.target.value)}
|
||||
placeholder="可选:写下您的服务感受"
|
||||
/>
|
||||
<button onClick={() => setShowRating(false)} className="text-sm text-neutral-400 hover:text-neutral-600">跳过</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user