import { useState, useEffect, useRef, useCallback } from 'react' import { Button, Dropdown, Input, Modal, Select, Spin, message as antMsg } from 'antd' import { CheckCircleOutlined, FileTextOutlined, FlagOutlined, PaperClipOutlined, SearchOutlined, SendOutlined, SmileOutlined, SwapOutlined, FilterOutlined, ExportOutlined, BookOutlined, PictureOutlined, } from '@ant-design/icons' import { useAuth } from '@/stores/auth' import { addSessionNote, claimSession, endSession, getAvailableAgents, getCustomers, getKnowledgeEntries, getSession, getSessions, markSessionRead, sendSessionMessage, transferSession, updateSessionPriority, type AvailableAgent, type Customer, type KnowledgeEntry, type Message, type Session, type SessionEvent, } from '@/services/api' const endReasons = [ { value: 'resolved', label: '已解决' }, { value: 'no_response', label: '无人回复' }, { value: 'visitor_left', label: '访客离开' }, { value: 'transferred', label: '已转接' }, { value: 'other', label: '其他' }, ] const quickReplies = [ '您好,正在为您查询,请稍候。', '感谢您的耐心等待,还有什么可以帮您?', '为更快处理,请您提供订单号或截图。', ] /** 列表项展示:紧急 / 等待中 / 进行中 */ function listStatusMeta(session: Session) { if (session.priority === 'urgent') { return { label: '紧急', bar: '#dc2626', avatarBg: '#fef2f2', avatarColor: '#dc2626', dot: '#dc2626' } } if (session.status === 'waiting') { return { label: '等待中', bar: '#d97706', avatarBg: '#fffbeb', avatarColor: '#d97706', dot: '#d97706' } } return { label: '进行中', bar: '#2563eb', avatarBg: '#eff6ff', avatarColor: '#2563eb', dot: '#2563eb' } } function relativeTime(iso?: string | null) { if (!iso) return '' const diff = Date.now() - new Date(iso).getTime() const mins = Math.floor(diff / 60000) if (mins < 1) return '刚刚' if (mins < 60) return `${mins}分钟前` const hours = Math.floor(mins / 60) if (hours < 24) return `${hours}小时前` const days = Math.floor(hours / 24) if (days < 7) return `${days}天前` return new Date(iso).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' }) } function parseTags(tagsStr: string): string[] { try { const parsed = JSON.parse(tagsStr) return Array.isArray(parsed) ? parsed : [] } catch { return tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [] } } function channelLabel(source?: string) { if (!source) return '网页' if (source.includes('微信') || source.toLowerCase().includes('wechat')) return '微信' if (source.toLowerCase().includes('app')) return 'APP' if (source.includes('电话')) return '电话' return source.length > 6 ? '网页' : source } interface SessionDetail { messages: Message[] events: SessionEvent[] pendingCount: number } const Dashboard = () => { const { user } = useAuth() const [sessions, setSessions] = useState([]) const [customers, setCustomers] = useState>({}) const [loading, setLoading] = useState(true) const [selectedId, setSelectedId] = useState(null) const [detail, setDetail] = useState(null) const [detailLoading, setDetailLoading] = useState(false) const [messageInput, setMessageInput] = useState('') const [search, setSearch] = useState('') const [sending, setSending] = useState(false) const [transferOpen, setTransferOpen] = useState(false) const [availableAgents, setAvailableAgents] = useState([]) const [targetAgentID, setTargetAgentID] = useState() const [endingOpen, setEndingOpen] = useState(false) const [endReason, setEndReason] = useState('resolved') const [knowledgeOpen, setKnowledgeOpen] = useState(false) const [knowledgeKeyword, setKnowledgeKeyword] = useState('') const [knowledgeEntries, setKnowledgeEntries] = useState([]) const [knowledgeLoading, setKnowledgeLoading] = useState(false) const [imagePreview, setImagePreview] = useState(null) const [noteInput, setNoteInput] = useState('') const [savingNote, setSavingNote] = useState(false) const [customerHistory, setCustomerHistory] = useState([]) const initialLoad = useRef(true) const chatEndRef = useRef(null) const fileInputRef = useRef(null) const socketRef = useRef(null) const lastTypingAt = useRef(0) const isManager = user?.role === 'admin' || user?.role === 'supervisor' const loadAll = useCallback(async () => { setLoading(true) try { const [sessionRes, customerRes] = await Promise.all([ getSessions({ page: 1, pageSize: 100 }), getCustomers({ page: 1, pageSize: 100 }), ]) const sessionList = Array.isArray(sessionRes.list) ? sessionRes.list : [] const customerList = Array.isArray(customerRes.list) ? customerRes.list : [] setSessions(sessionList) setCustomers(Object.fromEntries(customerList.map(customer => [customer.id, customer]))) if (sessionList.length > 0 && initialLoad.current) { const preferred = sessionList.find(session => session.status === 'active') || sessionList.find(session => session.priority === 'urgent') || sessionList[0] setSelectedId(preferred.id) initialLoad.current = false } } catch { antMsg.error('加载会话失败') setSessions([]) setCustomers({}) } finally { setLoading(false) } }, []) const loadDetail = useCallback(async (id: number, markRead = true) => { setDetailLoading(true) try { const response = await getSession(id) const data = response.data setDetail({ messages: data.messages || [], events: data.events || [], pendingCount: data.pending_count || 0 }) if (markRead) { await markSessionRead(id) setSessions(previous => previous.map(session => session.id === id ? { ...session, unread_count: 0 } : session)) } } catch { antMsg.error('加载消息失败') } finally { setDetailLoading(false) } setTimeout(() => chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 80) }, []) 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]) socketRef.current = socket 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 === 'message' || payload.type === 'session_created' || payload.type === 'session_updated') { loadAll() } } catch { // ignore } } return () => { socket.close() socketRef.current = null } }, [user?.token, selectedId, loadAll, loadDetail]) useEffect(() => { if (selectedId) loadDetail(selectedId) }, [selectedId, loadDetail]) useEffect(() => { if (!knowledgeOpen) return setKnowledgeLoading(true) getKnowledgeEntries({ search: knowledgeKeyword, page: 1 }).then(response => { setKnowledgeEntries(Array.isArray(response.list) ? response.list : []) }).catch(() => setKnowledgeEntries([])).finally(() => setKnowledgeLoading(false)) }, [knowledgeOpen, knowledgeKeyword]) const selected = sessions.find(session => session.id === selectedId) const selectedCustomer = selected ? customers[selected.customer_id] : null const canOperate = Boolean(selected && (isManager || selected.agent_id === user?.user_id) && selected.status === 'active') useEffect(() => { if (!selectedCustomer) { setCustomerHistory([]) return } // 从已加载会话中筛出该客户的历史(含已结束) getSessions({ page: 1, pageSize: 50 }).then(res => { const list = Array.isArray(res.list) ? res.list : [] setCustomerHistory( list .filter(s => s.customer_id === selectedCustomer.id && s.id !== selectedId) .slice(0, 5), ) }).catch(() => setCustomerHistory([])) }, [selectedCustomer?.id, selectedId]) const filteredSessions = sessions .filter(session => { if (session.status === 'ended') return false const customer = customers[session.customer_id] const keyword = search.trim().toLowerCase() return !keyword || customer?.name.toLowerCase().includes(keyword) || String(session.id).includes(keyword) || (session.last_message || '').toLowerCase().includes(keyword) }) .slice() .sort((a, b) => { const rank = (s: Session) => (s.priority === 'urgent' ? 0 : s.status === 'waiting' ? 1 : 2) const r = rank(a) - rank(b) if (r !== 0) return r const ta = new Date(a.last_message_at || a.created_at).getTime() const tb = new Date(b.last_message_at || b.created_at).getTime() return tb - ta }) const notes = detail?.events.filter(event => event.action === 'note').slice().reverse() || [] const emitTyping = () => { if (!selectedId || !canOperate) return const now = Date.now() if (now - lastTypingAt.current < 1200 || socketRef.current?.readyState !== WebSocket.OPEN) return lastTypingAt.current = now socketRef.current.send(JSON.stringify({ type: 'typing', session_id: selectedId })) } const sendMessage = async (content: string, type: 'text' | 'image' = 'text') => { if (!selectedId || !canOperate || sending || (type === 'text' && !content.trim())) return setSending(true) try { await sendSessionMessage(selectedId, content, type) if (type === 'text') setMessageInput('') await loadDetail(selectedId) await loadAll() } catch (error) { antMsg.error(error instanceof Error ? error.message : '发送失败') } finally { setSending(false) } } const handleImage = (file?: File) => { if (!file) return if (!['image/jpeg', 'image/png', 'image/gif'].includes(file.type)) { antMsg.error('仅支持 jpg、png、gif 图片') return } if (file.size > 5 * 1024 * 1024) { antMsg.error('图片不能超过 5 MB') return } const reader = new FileReader() reader.onload = () => setImagePreview(String(reader.result)) reader.readAsDataURL(file) } const handleClaim = async (sessionID: number) => { try { await claimSession(sessionID) antMsg.success('已领取会话') setSelectedId(sessionID) await loadAll() } catch (error) { antMsg.error(error instanceof Error ? error.message : '领取失败') } } const openTransfer = async () => { if (!selected) return try { const response = await getAvailableAgents() setAvailableAgents((response.data || []).filter(agent => agent.id !== user?.user_id)) setTargetAgentID(undefined) setTransferOpen(true) } catch { antMsg.error('加载在线客服失败') } } const handleTransfer = async () => { if (!selected || !targetAgentID) return try { await transferSession(selected.id, targetAgentID) antMsg.success('会话已转接') setTransferOpen(false) setSelectedId(null) setDetail(null) await loadAll() } catch (error) { antMsg.error(error instanceof Error ? error.message : '转接失败') } } const handlePriority = async (priority: 'normal' | 'urgent') => { if (!selected) return try { await updateSessionPriority(selected.id, priority) antMsg.success('优先级已更新') await loadAll() } catch (error) { antMsg.error(error instanceof Error ? error.message : '更新失败') } } const handleEnd = async () => { if (!selected) return try { await endSession(selected.id, endReason) antMsg.success('会话已结束') setEndingOpen(false) setSelectedId(null) setDetail(null) await loadAll() } catch (error) { antMsg.error(error instanceof Error ? error.message : '结束会话失败') } } const handleAddNote = async () => { if (!selected || !noteInput.trim()) return setSavingNote(true) try { await addSessionNote(selected.id, noteInput) setNoteInput('') await loadDetail(selected.id, false) } catch (error) { antMsg.error(error instanceof Error ? error.message : '保存备注失败') } finally { setSavingNote(false) } } const agentInitial = (user?.nickname || '客').slice(0, 1) if (loading) { return
} return (
{ handleImage(event.target.files?.[0]); event.currentTarget.value = '' }} /> {/* 会话列表面板 320px */}
setSearch(e.target.value)} />
当前会话 {filteredSessions.length} 个 预览访客窗口
{filteredSessions.length === 0 ? (
暂无待处理会话
) : filteredSessions.map(session => { const customer = customers[session.customer_id] const name = customer?.name || `客户${session.customer_id}` const meta = listStatusMeta(session) const selected = selectedId === session.id return ( ) })}
{/* 主聊天区 */}
{selected && selectedCustomer ? ( <>
{selectedCustomer.name.slice(0, 1)}
{selectedCustomer.name} {channelLabel(selectedCustomer.source)} {selectedCustomer.status === 'online' ? '在线' : selectedCustomer.status === 'busy' ? '忙碌' : '离线'}
会话 #{selected.id} {selectedCustomer.source && ( <> | {selectedCustomer.source} )}
handlePriority('urgent') }, { key: 'normal', label: '取消紧急', onClick: () => handlePriority('normal') }, ], }} disabled={!canOperate} >
{detailLoading ? (
) : ( <>
会话开始 — {new Date(selected.created_at).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
{(detail?.messages || []).map(message => { const isAgent = message.sender_type === 'agent' const visitorInitial = selectedCustomer.name.slice(0, 1) return (
{isAgent ? agentInitial : visitorInitial}
{message.type === 'image' ? ( 聊天图片 ) : (

{message.content}

)}
{new Date(message.sent_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
) })} {detail?.messages.length === 0 && (
暂无消息,开始对话吧
)}
)}
{selected.status !== 'active' || !canOperate ? (
{selected.status === 'waiting' ? '领取会话后即可回复' : '会话已结束,无法继续发送消息'}
) : ( <>
({ key: String(index), label: content, onClick: () => setMessageInput(content), })), }} >
{ setMessageInput(event.target.value); emitTyping() }} onPaste={event => { const image = Array.from(event.clipboardData.items).find(item => item.type.startsWith('image/')) if (image) { event.preventDefault() handleImage(image.getAsFile() || undefined) } }} onKeyDown={event => { if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault() sendMessage(messageInput) } }} />
)}
) : (
选择一个会话开始处理
)}
{/* 右侧客户信息 320px */} setTransferOpen(false)} onOk={handleTransfer} okButtonProps={{ disabled: !targetAgentID }}>

请选择一位在线客服接手当前会话。

setImagePreview(null)} onOk={() => { if (imagePreview) sendMessage(imagePreview, 'image'); setImagePreview(null) }} okText="发送" okButtonProps={{ loading: sending }} >
待发送图片预览
setKnowledgeOpen(false)} footer={null} width={640}> } placeholder="搜索标题或内容" value={knowledgeKeyword} onChange={event => setKnowledgeKeyword(event.target.value)} allowClear className="mb-3" /> {knowledgeLoading ? (
) : (
{knowledgeEntries.length === 0 ? (
未找到可用知识条目
) : knowledgeEntries.map(entry => ( ))}
)}
) } export default Dashboard