From d105840b1fdf2eb6b5482e26170f8c5ac7dd3a22 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 15 Jul 2026 11:05:37 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E5=85=A8=20P0=EF=BC=9A=E7=AD=9B?= =?UTF-8?q?=E9=80=89=E3=80=81=E8=AE=BF=E5=AE=A2=E5=9B=BE=E7=89=87=E4=B8=8E?= =?UTF-8?q?=E5=8F=8C=E5=90=91=E8=BE=93=E5=85=A5=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 工作台会话列表支持状态/紧急筛选 - 访客 Widget 支持 jpg/png/gif 图片预览发送 - WebSocket 支持访客输入中通知客服,客服输入中通知访客 - 工作台聊天区展示「访客正在输入」动画 --- server/internal/ws/ws.go | 45 ++++++++- server/internal/ws/ws_test.go | 31 ++++++ web/src/pages/agent/Dashboard.tsx | 131 ++++++++++++++++++++++++-- web/src/widgets/VisitorChat.tsx | 150 +++++++++++++++++++++++++----- 4 files changed, 326 insertions(+), 31 deletions(-) diff --git a/server/internal/ws/ws.go b/server/internal/ws/ws.go index f68d36d..c51f34f 100644 --- a/server/internal/ws/ws.go +++ b/server/internal/ws/ws.go @@ -135,21 +135,62 @@ func (h *Hub) BroadcastToVisitor(tenantID, sessionID uint, message []byte) { } } +// BroadcastToSessionStaff 仅推送给可查看该会话的工作人员(不含访客)。 +func (h *Hub) BroadcastToSessionStaff(tenantID uint, agentID *uint, message []byte) { + h.mu.RLock() + defer h.mu.RUnlock() + + for client := range h.clients { + if client.TenantID != tenantID || client.Kind != "agent" { + continue + } + if client.Role == "admin" || client.Role == "supervisor" || + (agentID != nil && client.Role == "agent" && client.UserID == *agentID) { + h.send(client, message) + } + } +} + func handleClientEvent(client *Client, event ClientEvent) { - if client.Kind != "agent" || event.Type != "typing" || event.SessionID == 0 { + if event.Type != "typing" || event.SessionID == 0 { return } var session model.Session if err := model.DB.Where("id = ? AND tenant_id = ?", event.SessionID, client.TenantID).First(&session).Error; err != nil { return } + + // 访客输入中 → 通知可接待的客服 + if client.Kind == "visitor" { + if client.SessionID == nil || *client.SessionID != event.SessionID { + return + } + if session.Status == "ended" || session.Status == "archived" { + return + } + payload, err := NewEvent("typing", session.ID, map[string]string{"from": "visitor"}) + if err != nil { + return + } + if session.AgentID == nil { + DefaultHub.BroadcastToTenantStaff(session.TenantID, payload) + return + } + DefaultHub.BroadcastToSessionStaff(session.TenantID, session.AgentID, payload) + return + } + + // 客服输入中 → 通知访客 + if client.Kind != "agent" { + return + } if client.Role == "agent" && (session.AgentID == nil || *session.AgentID != client.UserID) { return } if client.Role != "agent" && client.Role != "admin" && client.Role != "supervisor" { return } - payload, err := NewEvent("typing", session.ID, nil) + payload, err := NewEvent("typing", session.ID, map[string]string{"from": "agent"}) if err == nil { DefaultHub.BroadcastToVisitor(session.TenantID, session.ID, payload) } diff --git a/server/internal/ws/ws_test.go b/server/internal/ws/ws_test.go index c86a0fe..adf5247 100644 --- a/server/internal/ws/ws_test.go +++ b/server/internal/ws/ws_test.go @@ -67,3 +67,34 @@ func TestBroadcastToVisitorRestrictsSession(t *testing.T) { } } } + +func TestBroadcastToSessionStaffExcludesVisitor(t *testing.T) { + hub := NewHub() + go hub.Run() + + sessionID := uint(31) + agentID := uint(3) + assigned := &Client{TenantID: 1, UserID: agentID, Role: "agent", Kind: "agent", Send: make(chan []byte, 1)} + otherAgent := &Client{TenantID: 1, UserID: 4, Role: "agent", Kind: "agent", Send: make(chan []byte, 1)} + supervisor := &Client{TenantID: 1, UserID: 5, Role: "supervisor", Kind: "agent", Send: make(chan []byte, 1)} + visitor := &Client{TenantID: 1, Kind: "visitor", SessionID: &sessionID, Send: make(chan []byte, 1)} + for _, client := range []*Client{assigned, otherAgent, supervisor, visitor} { + hub.register <- client + } + + hub.BroadcastToSessionStaff(1, &agentID, []byte(`{"type":"typing","data":{"from":"visitor"}}`)) + for _, client := range []*Client{assigned, supervisor} { + select { + case <-client.Send: + case <-time.After(time.Second): + t.Fatalf("工作人员未收到输入状态:%+v", client) + } + } + for _, client := range []*Client{otherAgent, visitor} { + select { + case <-client.Send: + t.Fatalf("不应收到的客户端收到消息:%+v", client) + default: + } + } +} diff --git a/web/src/pages/agent/Dashboard.tsx b/web/src/pages/agent/Dashboard.tsx index af64f0f..49c2823 100644 --- a/web/src/pages/agent/Dashboard.tsx +++ b/web/src/pages/agent/Dashboard.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef, useCallback } from 'react' -import { Button, Dropdown, Input, Modal, Select, Spin, message as antMsg } from 'antd' +import { Button, Dropdown, Input, Modal, Select, Spin, message as antMsg, Popover } from 'antd' import { CheckCircleOutlined, FileTextOutlined, FlagOutlined, PaperClipOutlined, SearchOutlined, SendOutlined, SmileOutlined, SwapOutlined, FilterOutlined, @@ -96,11 +96,16 @@ const Dashboard = () => { const [noteInput, setNoteInput] = useState('') const [savingNote, setSavingNote] = useState(false) const [customerHistory, setCustomerHistory] = useState([]) + const [statusFilter, setStatusFilter] = useState<'all' | 'waiting' | 'active'>('all') + const [priorityFilter, setPriorityFilter] = useState<'all' | 'urgent'>('all') + const [filterOpen, setFilterOpen] = useState(false) + const [visitorTyping, setVisitorTyping] = useState(false) const initialLoad = useRef(true) const chatEndRef = useRef(null) const fileInputRef = useRef(null) const socketRef = useRef(null) const lastTypingAt = useRef(0) + const visitorTypingTimer = useRef(null) const isManager = user?.role === 'admin' || user?.role === 'supervisor' @@ -159,7 +164,14 @@ const Dashboard = () => { socket.onmessage = event => { try { const payload = JSON.parse(event.data) + if (payload.type === 'typing' && payload.session_id === selectedId && payload.data?.from === 'visitor') { + setVisitorTyping(true) + if (visitorTypingTimer.current) clearTimeout(visitorTypingTimer.current) + visitorTypingTimer.current = window.setTimeout(() => setVisitorTyping(false), 1800) + return + } if (payload.type === 'message' && payload.session_id === selectedId) { + setVisitorTyping(false) loadDetail(payload.session_id) } if (payload.type === 'message' || payload.type === 'session_created' || payload.type === 'session_updated') { @@ -172,9 +184,20 @@ const Dashboard = () => { return () => { socket.close() socketRef.current = null + if (visitorTypingTimer.current) clearTimeout(visitorTypingTimer.current) } }, [user?.token, selectedId, loadAll, loadDetail]) + useEffect(() => { + setVisitorTyping(false) + }, [selectedId]) + + useEffect(() => { + if (visitorTyping) { + setTimeout(() => chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 40) + } + }, [visitorTyping]) + useEffect(() => { if (selectedId) loadDetail(selectedId) }, [selectedId, loadDetail]) @@ -207,9 +230,12 @@ const Dashboard = () => { }).catch(() => setCustomerHistory([])) }, [selectedCustomer?.id, selectedId]) + const filterActive = statusFilter !== 'all' || priorityFilter !== 'all' const filteredSessions = sessions .filter(session => { if (session.status === 'ended') return false + if (statusFilter !== 'all' && session.status !== statusFilter) return false + if (priorityFilter === 'urgent' && session.priority !== 'urgent') return false const customer = customers[session.customer_id] const keyword = search.trim().toLowerCase() return !keyword @@ -372,13 +398,83 @@ const Dashboard = () => { onChange={e => setSearch(e.target.value)} /> - + ))} + + +
+
优先级
+
+ {([ + { key: 'all', label: '全部' }, + { key: 'urgent', label: '仅紧急' }, + ] as const).map(item => ( + + ))} +
+
+ {filterActive && ( + + )} + + )} > - - + +
@@ -581,6 +677,27 @@ const Dashboard = () => { {detail?.messages.length === 0 && (
暂无消息,开始对话吧
)} + {visitorTyping && selectedCustomer && ( +
+
+ {selectedCustomer.name.slice(0, 1)} +
+
+
+ + + +
+
访客正在输入…
+
+
+ )}
)} diff --git a/web/src/widgets/VisitorChat.tsx b/web/src/widgets/VisitorChat.tsx index 4972239..fe35307 100644 --- a/web/src/widgets/VisitorChat.tsx +++ b/web/src/widgets/VisitorChat.tsx @@ -35,11 +35,15 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => { const [agentTyping, setAgentTyping] = useState(false) const [ratingText, setRatingText] = useState('') const [hoverStar, setHoverStar] = useState(0) + const [imagePreview, setImagePreview] = useState(null) + const [sendError, setSendError] = useState('') const pollRef = useRef(null) const initRef = useRef(false) const typingTimerRef = useRef(null) const chatEndRef = useRef(null) const fileInputRef = useRef(null) + const socketRef = useRef(null) + const lastTypingAt = useRef(0) const loadMessages = useCallback(async (sid?: number, token?: string) => { const s = sid || sessionId @@ -106,10 +110,13 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => { `${scheme}//${window.location.host}/api/widget/ws?session_id=${sessionId}`, ['kefu-visitor-v1', visitorToken], ) + socketRef.current = socket socket.onmessage = (event) => { try { const payload = JSON.parse(event.data) if (payload.session_id === sessionId && payload.type === 'typing') { + // 仅展示客服侧输入状态 + if (payload.data?.from && payload.data.from !== 'agent') return setAgentTyping(true) if (typingTimerRef.current) clearTimeout(typingTimerRef.current) typingTimerRef.current = window.setTimeout(() => setAgentTyping(false), 1800) @@ -129,18 +136,42 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => { } return () => { socket.close() + socketRef.current = null if (typingTimerRef.current) clearTimeout(typingTimerRef.current) } }, [sessionId, visitorToken, open, loadMessages]) useEffect(() => { chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }) - }, [messages, agentTyping, open]) + }, [messages, agentTyping, open, imagePreview]) + + const emitTyping = () => { + if (!sessionId || sessionEnded) 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: sessionId })) + } + + const postMessage = async (content: string, type: 'text' | 'image') => { + if (!sessionId || !visitorToken) throw new Error('会话未就绪') + const res = await fetch('/api/widget/message', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Visitor-Token': visitorToken }, + body: JSON.stringify({ session_id: sessionId, content, type }), + }) + const json = await res.json() + if (json.code !== 0) { + throw new Error(json.message || '发送失败') + } + return json.data + } const sendMessage = async (text: string) => { if (!text.trim() || sending || sessionEnded) return const content = text.trim() setInput('') + setSendError('') setSending(true) const localMsg: Message = { @@ -152,17 +183,55 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => { } setMessages(prev => [...prev, localMsg]) - if (sessionId && visitorToken) { - try { - await fetch('/api/widget/message', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Visitor-Token': visitorToken }, - body: JSON.stringify({ session_id: sessionId, content, type: 'text' }), - }) - loadMessages(sessionId) - } catch { /* ignore */ } + try { + await postMessage(content, 'text') + await loadMessages(sessionId || undefined) + } catch (e) { + setSendError(e instanceof Error ? e.message : '发送失败') + setMessages(prev => prev.filter(m => m.id !== localMsg.id)) + } finally { + setSending(false) + } + } + + const handleImageFile = (file?: File | null) => { + if (!file || sessionEnded || !sessionId) return + if (!['image/jpeg', 'image/png', 'image/gif'].includes(file.type)) { + setSendError('仅支持 jpg、png、gif 图片') + return + } + if (file.size > 5 * 1024 * 1024) { + setSendError('图片不能超过 5 MB') + return + } + setSendError('') + const reader = new FileReader() + reader.onload = () => setImagePreview(String(reader.result)) + reader.readAsDataURL(file) + } + + const sendImage = async () => { + if (!imagePreview || sending || sessionEnded) return + setSending(true) + setSendError('') + const localMsg: Message = { + id: -Date.now(), + sender: 'visitor', + content: imagePreview, + type: 'image', + time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }), + } + setMessages(prev => [...prev, localMsg]) + setImagePreview(null) + try { + await postMessage(localMsg.content, 'image') + await loadMessages(sessionId || undefined) + } catch (e) { + setSendError(e instanceof Error ? e.message : '图片发送失败') + setMessages(prev => prev.filter(m => m.id !== localMsg.id)) + } finally { + setSending(false) } - setSending(false) } const handleOpen = () => { @@ -219,7 +288,6 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => { className="fixed z-50 right-6 bottom-6 w-[400px] h-[600px] max-w-[calc(100vw-32px)] max-h-[calc(100vh-32px)] flex flex-col rounded-xl overflow-hidden bg-white" style={{ boxShadow: 'var(--shadow-floating)' }} > - {/* Header — solid brand blue */}
@@ -251,7 +319,6 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
- {/* Messages */}
{showWelcome && (
@@ -282,9 +349,9 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => { {messages.map(msg => ( msg.sender === 'visitor' ? (
-
+
{msg.type === 'image' ? ( - 图片 + 图片 ) : (

{msg.content}

)} @@ -295,9 +362,9 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
-
+
{msg.type === 'image' ? ( - 图片 + 图片 ) : (

{msg.content}

)} @@ -324,19 +391,42 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
- {/* Footer */}