修复会话安全与实时消息

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
+28
View File
@@ -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
+7 -2
View File
@@ -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"
/>
+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>
)
}
+31 -15
View File
@@ -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
View File
@@ -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 }) => {
+9 -8
View File
@@ -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)
}
+99 -34
View File
@@ -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>