补全 P0:筛选、访客图片与双向输入状态
- 工作台会话列表支持状态/紧急筛选 - 访客 Widget 支持 jpg/png/gif 图片预览发送 - WebSocket 支持访客输入中通知客服,客服输入中通知访客 - 工作台聊天区展示「访客正在输入」动画
This commit is contained in:
@@ -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) {
|
func handleClientEvent(client *Client, event ClientEvent) {
|
||||||
if client.Kind != "agent" || event.Type != "typing" || event.SessionID == 0 {
|
if event.Type != "typing" || event.SessionID == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var session model.Session
|
var session model.Session
|
||||||
if err := model.DB.Where("id = ? AND tenant_id = ?", event.SessionID, client.TenantID).First(&session).Error; err != nil {
|
if err := model.DB.Where("id = ? AND tenant_id = ?", event.SessionID, client.TenantID).First(&session).Error; err != nil {
|
||||||
return
|
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) {
|
if client.Role == "agent" && (session.AgentID == nil || *session.AgentID != client.UserID) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if client.Role != "agent" && client.Role != "admin" && client.Role != "supervisor" {
|
if client.Role != "agent" && client.Role != "admin" && client.Role != "supervisor" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
payload, err := NewEvent("typing", session.ID, nil)
|
payload, err := NewEvent("typing", session.ID, map[string]string{"from": "agent"})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
DefaultHub.BroadcastToVisitor(session.TenantID, session.ID, payload)
|
DefaultHub.BroadcastToVisitor(session.TenantID, session.ID, payload)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
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 {
|
import {
|
||||||
CheckCircleOutlined, FileTextOutlined, FlagOutlined, PaperClipOutlined,
|
CheckCircleOutlined, FileTextOutlined, FlagOutlined, PaperClipOutlined,
|
||||||
SearchOutlined, SendOutlined, SmileOutlined, SwapOutlined, FilterOutlined,
|
SearchOutlined, SendOutlined, SmileOutlined, SwapOutlined, FilterOutlined,
|
||||||
@@ -96,11 +96,16 @@ const Dashboard = () => {
|
|||||||
const [noteInput, setNoteInput] = useState('')
|
const [noteInput, setNoteInput] = useState('')
|
||||||
const [savingNote, setSavingNote] = useState(false)
|
const [savingNote, setSavingNote] = useState(false)
|
||||||
const [customerHistory, setCustomerHistory] = useState<Session[]>([])
|
const [customerHistory, setCustomerHistory] = useState<Session[]>([])
|
||||||
|
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 initialLoad = useRef(true)
|
||||||
const chatEndRef = useRef<HTMLDivElement>(null)
|
const chatEndRef = useRef<HTMLDivElement>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const socketRef = useRef<WebSocket | null>(null)
|
const socketRef = useRef<WebSocket | null>(null)
|
||||||
const lastTypingAt = useRef(0)
|
const lastTypingAt = useRef(0)
|
||||||
|
const visitorTypingTimer = useRef<number | null>(null)
|
||||||
|
|
||||||
const isManager = user?.role === 'admin' || user?.role === 'supervisor'
|
const isManager = user?.role === 'admin' || user?.role === 'supervisor'
|
||||||
|
|
||||||
@@ -159,7 +164,14 @@ const Dashboard = () => {
|
|||||||
socket.onmessage = event => {
|
socket.onmessage = event => {
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(event.data)
|
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) {
|
if (payload.type === 'message' && payload.session_id === selectedId) {
|
||||||
|
setVisitorTyping(false)
|
||||||
loadDetail(payload.session_id)
|
loadDetail(payload.session_id)
|
||||||
}
|
}
|
||||||
if (payload.type === 'message' || payload.type === 'session_created' || payload.type === 'session_updated') {
|
if (payload.type === 'message' || payload.type === 'session_created' || payload.type === 'session_updated') {
|
||||||
@@ -172,9 +184,20 @@ const Dashboard = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
socket.close()
|
socket.close()
|
||||||
socketRef.current = null
|
socketRef.current = null
|
||||||
|
if (visitorTypingTimer.current) clearTimeout(visitorTypingTimer.current)
|
||||||
}
|
}
|
||||||
}, [user?.token, selectedId, loadAll, loadDetail])
|
}, [user?.token, selectedId, loadAll, loadDetail])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setVisitorTyping(false)
|
||||||
|
}, [selectedId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visitorTyping) {
|
||||||
|
setTimeout(() => chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 40)
|
||||||
|
}
|
||||||
|
}, [visitorTyping])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedId) loadDetail(selectedId)
|
if (selectedId) loadDetail(selectedId)
|
||||||
}, [selectedId, loadDetail])
|
}, [selectedId, loadDetail])
|
||||||
@@ -207,9 +230,12 @@ const Dashboard = () => {
|
|||||||
}).catch(() => setCustomerHistory([]))
|
}).catch(() => setCustomerHistory([]))
|
||||||
}, [selectedCustomer?.id, selectedId])
|
}, [selectedCustomer?.id, selectedId])
|
||||||
|
|
||||||
|
const filterActive = statusFilter !== 'all' || priorityFilter !== 'all'
|
||||||
const filteredSessions = sessions
|
const filteredSessions = sessions
|
||||||
.filter(session => {
|
.filter(session => {
|
||||||
if (session.status === 'ended') return false
|
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 customer = customers[session.customer_id]
|
||||||
const keyword = search.trim().toLowerCase()
|
const keyword = search.trim().toLowerCase()
|
||||||
return !keyword
|
return !keyword
|
||||||
@@ -372,13 +398,83 @@ const Dashboard = () => {
|
|||||||
onChange={e => setSearch(e.target.value)}
|
onChange={e => setSearch(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<Popover
|
||||||
|
open={filterOpen}
|
||||||
|
onOpenChange={setFilterOpen}
|
||||||
|
trigger="click"
|
||||||
|
placement="bottomRight"
|
||||||
|
content={(
|
||||||
|
<div className="w-52 space-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-neutral-400 mb-1.5">会话状态</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{([
|
||||||
|
{ key: 'all', label: '全部' },
|
||||||
|
{ key: 'waiting', label: '等待中' },
|
||||||
|
{ key: 'active', label: '进行中' },
|
||||||
|
] as const).map(item => (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStatusFilter(item.key)}
|
||||||
|
className={`px-2 py-0.5 rounded-md text-xs border ${
|
||||||
|
statusFilter === item.key
|
||||||
|
? 'bg-[#dbeafe] border-[#2563eb] text-[#2563eb]'
|
||||||
|
: 'bg-white border-neutral-200 text-neutral-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-neutral-400 mb-1.5">优先级</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{([
|
||||||
|
{ key: 'all', label: '全部' },
|
||||||
|
{ key: 'urgent', label: '仅紧急' },
|
||||||
|
] as const).map(item => (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPriorityFilter(item.key)}
|
||||||
|
className={`px-2 py-0.5 rounded-md text-xs border ${
|
||||||
|
priorityFilter === item.key
|
||||||
|
? 'bg-[#dbeafe] border-[#2563eb] text-[#2563eb]'
|
||||||
|
: 'bg-white border-neutral-200 text-neutral-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{filterActive && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-[34px] h-[34px] rounded-lg bg-neutral-100 border border-neutral-200 text-neutral-500 flex items-center justify-center shrink-0"
|
className="text-xs text-neutral-500 hover:text-neutral-700"
|
||||||
|
onClick={() => { setStatusFilter('all'); setPriorityFilter('all') }}
|
||||||
|
>
|
||||||
|
清除筛选
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`w-[34px] h-[34px] rounded-lg border flex items-center justify-center shrink-0 relative ${
|
||||||
|
filterActive
|
||||||
|
? 'bg-[#dbeafe] border-[#2563eb] text-[#2563eb]'
|
||||||
|
: 'bg-neutral-100 border-neutral-200 text-neutral-500'
|
||||||
|
}`}
|
||||||
title="筛选"
|
title="筛选"
|
||||||
>
|
>
|
||||||
<FilterOutlined className="text-xs" />
|
<FilterOutlined className="text-xs" />
|
||||||
|
{filterActive && <span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-[#2563eb]" />}
|
||||||
</button>
|
</button>
|
||||||
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-[#dbeafe] text-[#2563eb]">
|
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-[#dbeafe] text-[#2563eb]">
|
||||||
@@ -581,6 +677,27 @@ const Dashboard = () => {
|
|||||||
{detail?.messages.length === 0 && (
|
{detail?.messages.length === 0 && (
|
||||||
<div className="text-center text-sm text-neutral-400 py-10">暂无消息,开始对话吧</div>
|
<div className="text-center text-sm text-neutral-400 py-10">暂无消息,开始对话吧</div>
|
||||||
)}
|
)}
|
||||||
|
{visitorTyping && selectedCustomer && (
|
||||||
|
<div className="flex items-start gap-2.5 mb-2">
|
||||||
|
<div
|
||||||
|
className="w-9 h-9 rounded-full flex items-center justify-center shrink-0 text-sm font-semibold"
|
||||||
|
style={{
|
||||||
|
background: listStatusMeta(selected).avatarBg,
|
||||||
|
color: listStatusMeta(selected).avatarColor,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedCustomer.name.slice(0, 1)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="px-3.5 py-2.5 rounded-xl rounded-tl-sm bg-white border border-neutral-100 inline-flex items-center gap-1">
|
||||||
|
<span className="typing-dot" style={{ animationDelay: '0s' }} />
|
||||||
|
<span className="typing-dot" style={{ animationDelay: '0.2s' }} />
|
||||||
|
<span className="typing-dot" style={{ animationDelay: '0.4s' }} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-neutral-400">访客正在输入…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div ref={chatEndRef} />
|
<div ref={chatEndRef} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+127
-21
@@ -35,11 +35,15 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
|||||||
const [agentTyping, setAgentTyping] = useState(false)
|
const [agentTyping, setAgentTyping] = useState(false)
|
||||||
const [ratingText, setRatingText] = useState('')
|
const [ratingText, setRatingText] = useState('')
|
||||||
const [hoverStar, setHoverStar] = useState(0)
|
const [hoverStar, setHoverStar] = useState(0)
|
||||||
|
const [imagePreview, setImagePreview] = useState<string | null>(null)
|
||||||
|
const [sendError, setSendError] = useState('')
|
||||||
const pollRef = useRef<number | null>(null)
|
const pollRef = useRef<number | null>(null)
|
||||||
const initRef = useRef(false)
|
const initRef = useRef(false)
|
||||||
const typingTimerRef = useRef<number | null>(null)
|
const typingTimerRef = useRef<number | null>(null)
|
||||||
const chatEndRef = useRef<HTMLDivElement>(null)
|
const chatEndRef = useRef<HTMLDivElement>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const socketRef = useRef<WebSocket | null>(null)
|
||||||
|
const lastTypingAt = useRef(0)
|
||||||
|
|
||||||
const loadMessages = useCallback(async (sid?: number, token?: string) => {
|
const loadMessages = useCallback(async (sid?: number, token?: string) => {
|
||||||
const s = sid || sessionId
|
const s = sid || sessionId
|
||||||
@@ -106,10 +110,13 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
|||||||
`${scheme}//${window.location.host}/api/widget/ws?session_id=${sessionId}`,
|
`${scheme}//${window.location.host}/api/widget/ws?session_id=${sessionId}`,
|
||||||
['kefu-visitor-v1', visitorToken],
|
['kefu-visitor-v1', visitorToken],
|
||||||
)
|
)
|
||||||
|
socketRef.current = socket
|
||||||
socket.onmessage = (event) => {
|
socket.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(event.data)
|
const payload = JSON.parse(event.data)
|
||||||
if (payload.session_id === sessionId && payload.type === 'typing') {
|
if (payload.session_id === sessionId && payload.type === 'typing') {
|
||||||
|
// 仅展示客服侧输入状态
|
||||||
|
if (payload.data?.from && payload.data.from !== 'agent') return
|
||||||
setAgentTyping(true)
|
setAgentTyping(true)
|
||||||
if (typingTimerRef.current) clearTimeout(typingTimerRef.current)
|
if (typingTimerRef.current) clearTimeout(typingTimerRef.current)
|
||||||
typingTimerRef.current = window.setTimeout(() => setAgentTyping(false), 1800)
|
typingTimerRef.current = window.setTimeout(() => setAgentTyping(false), 1800)
|
||||||
@@ -129,18 +136,42 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
|||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
socket.close()
|
socket.close()
|
||||||
|
socketRef.current = null
|
||||||
if (typingTimerRef.current) clearTimeout(typingTimerRef.current)
|
if (typingTimerRef.current) clearTimeout(typingTimerRef.current)
|
||||||
}
|
}
|
||||||
}, [sessionId, visitorToken, open, loadMessages])
|
}, [sessionId, visitorToken, open, loadMessages])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
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) => {
|
const sendMessage = async (text: string) => {
|
||||||
if (!text.trim() || sending || sessionEnded) return
|
if (!text.trim() || sending || sessionEnded) return
|
||||||
const content = text.trim()
|
const content = text.trim()
|
||||||
setInput('')
|
setInput('')
|
||||||
|
setSendError('')
|
||||||
setSending(true)
|
setSending(true)
|
||||||
|
|
||||||
const localMsg: Message = {
|
const localMsg: Message = {
|
||||||
@@ -152,18 +183,56 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
|||||||
}
|
}
|
||||||
setMessages(prev => [...prev, localMsg])
|
setMessages(prev => [...prev, localMsg])
|
||||||
|
|
||||||
if (sessionId && visitorToken) {
|
|
||||||
try {
|
try {
|
||||||
await fetch('/api/widget/message', {
|
await postMessage(content, 'text')
|
||||||
method: 'POST',
|
await loadMessages(sessionId || undefined)
|
||||||
headers: { 'Content-Type': 'application/json', 'X-Visitor-Token': visitorToken },
|
} catch (e) {
|
||||||
body: JSON.stringify({ session_id: sessionId, content, type: 'text' }),
|
setSendError(e instanceof Error ? e.message : '发送失败')
|
||||||
})
|
setMessages(prev => prev.filter(m => m.id !== localMsg.id))
|
||||||
loadMessages(sessionId)
|
} finally {
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
setSending(false)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleOpen = () => {
|
const handleOpen = () => {
|
||||||
setOpen(true)
|
setOpen(true)
|
||||||
@@ -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"
|
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)' }}
|
style={{ boxShadow: 'var(--shadow-floating)' }}
|
||||||
>
|
>
|
||||||
{/* Header — solid brand blue */}
|
|
||||||
<header className="shrink-0 px-4 py-4 bg-[#2563eb] text-white flex items-center gap-3">
|
<header className="shrink-0 px-4 py-4 bg-[#2563eb] text-white flex items-center gap-3">
|
||||||
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center shrink-0">
|
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center shrink-0">
|
||||||
<CustomerServiceOutlined className="text-lg text-white" />
|
<CustomerServiceOutlined className="text-lg text-white" />
|
||||||
@@ -251,7 +319,6 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Messages */}
|
|
||||||
<section className="flex-1 overflow-y-auto p-4 flex flex-col gap-3 bg-neutral-50 no-scrollbar">
|
<section className="flex-1 overflow-y-auto p-4 flex flex-col gap-3 bg-neutral-50 no-scrollbar">
|
||||||
{showWelcome && (
|
{showWelcome && (
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
@@ -282,9 +349,9 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
|||||||
{messages.map(msg => (
|
{messages.map(msg => (
|
||||||
msg.sender === 'visitor' ? (
|
msg.sender === 'visitor' ? (
|
||||||
<div key={msg.id} className="flex justify-end max-w-[85%] ml-auto">
|
<div key={msg.id} className="flex justify-end max-w-[85%] ml-auto">
|
||||||
<div className="px-4 py-3 rounded-xl bg-[#2563eb] text-white text-[13px] leading-normal">
|
<div className={`rounded-xl bg-[#2563eb] text-white text-[13px] leading-normal ${msg.type === 'image' ? 'p-1.5' : 'px-4 py-3'}`}>
|
||||||
{msg.type === 'image' ? (
|
{msg.type === 'image' ? (
|
||||||
<img src={msg.content} alt="图片" className="max-w-[180px] rounded-lg" />
|
<img src={msg.content} alt="图片" className="max-w-[180px] rounded-lg block" />
|
||||||
) : (
|
) : (
|
||||||
<p className="m-0 whitespace-pre-wrap break-words">{msg.content}</p>
|
<p className="m-0 whitespace-pre-wrap break-words">{msg.content}</p>
|
||||||
)}
|
)}
|
||||||
@@ -295,9 +362,9 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
|||||||
<div className="w-8 h-8 rounded-full bg-neutral-200 flex items-center justify-center shrink-0 mt-0.5">
|
<div className="w-8 h-8 rounded-full bg-neutral-200 flex items-center justify-center shrink-0 mt-0.5">
|
||||||
<CustomerServiceOutlined className="text-xs text-neutral-500" />
|
<CustomerServiceOutlined className="text-xs text-neutral-500" />
|
||||||
</div>
|
</div>
|
||||||
<div className="px-4 py-3 rounded-xl bg-white border border-neutral-200 text-[13px] text-neutral-800 leading-normal">
|
<div className={`rounded-xl bg-white border border-neutral-200 text-[13px] text-neutral-800 leading-normal ${msg.type === 'image' ? 'p-1.5' : 'px-4 py-3'}`}>
|
||||||
{msg.type === 'image' ? (
|
{msg.type === 'image' ? (
|
||||||
<img src={msg.content} alt="图片" className="max-w-[180px] rounded-lg" />
|
<img src={msg.content} alt="图片" className="max-w-[180px] rounded-lg block" />
|
||||||
) : (
|
) : (
|
||||||
<p className="m-0 whitespace-pre-wrap break-words">{msg.content}</p>
|
<p className="m-0 whitespace-pre-wrap break-words">{msg.content}</p>
|
||||||
)}
|
)}
|
||||||
@@ -324,19 +391,42 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
|||||||
<div ref={chatEndRef} />
|
<div ref={chatEndRef} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer className="shrink-0 relative px-4 py-3 bg-white border-t border-neutral-200">
|
<footer className="shrink-0 relative px-4 py-3 bg-white border-t border-neutral-200">
|
||||||
<div
|
<div
|
||||||
className="absolute top-0 left-0 right-0 h-[3px] pointer-events-none opacity-40"
|
className="absolute top-0 left-0 right-0 h-[3px] pointer-events-none opacity-40"
|
||||||
style={{ background: 'linear-gradient(90deg, transparent 0%, #2563eb 50%, transparent 100%)' }}
|
style={{ background: 'linear-gradient(90deg, transparent 0%, #2563eb 50%, transparent 100%)' }}
|
||||||
/>
|
/>
|
||||||
|
{sendError && (
|
||||||
|
<div className="mb-2 text-xs text-red-500">{sendError}</div>
|
||||||
|
)}
|
||||||
|
{imagePreview && (
|
||||||
|
<div className="mb-2 p-2 rounded-lg border border-neutral-200 bg-neutral-50 flex items-center gap-2">
|
||||||
|
<img src={imagePreview} alt="预览" className="w-14 h-14 object-cover rounded" />
|
||||||
|
<div className="flex-1 min-w-0 text-xs text-neutral-500">确认发送图片?</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs text-neutral-400 hover:text-neutral-600 border-0 bg-transparent cursor-pointer"
|
||||||
|
onClick={() => setImagePreview(null)}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={sending}
|
||||||
|
className="text-xs px-2 py-1 rounded-md bg-[#2563eb] text-white border-0 cursor-pointer disabled:opacity-50"
|
||||||
|
onClick={sendImage}
|
||||||
|
>
|
||||||
|
发送
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="w-8 h-8 rounded-md border-0 bg-transparent cursor-pointer flex items-center justify-center text-neutral-400 hover:bg-neutral-50"
|
className="w-8 h-8 rounded-md border-0 bg-transparent cursor-pointer flex items-center justify-center text-neutral-400 hover:bg-neutral-50 disabled:opacity-40"
|
||||||
aria-label="发送图片"
|
aria-label="发送图片"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
disabled={sessionEnded || !sessionId}
|
disabled={sessionEnded || !sessionId || sending}
|
||||||
>
|
>
|
||||||
<PictureOutlined className="text-base" />
|
<PictureOutlined className="text-base" />
|
||||||
</button>
|
</button>
|
||||||
@@ -347,14 +437,30 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
|||||||
>
|
>
|
||||||
<SmileOutlined className="text-base" />
|
<SmileOutlined className="text-base" />
|
||||||
</button>
|
</button>
|
||||||
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" />
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/gif"
|
||||||
|
className="hidden"
|
||||||
|
onChange={e => {
|
||||||
|
handleImageFile(e.target.files?.[0])
|
||||||
|
e.currentTarget.value = ''
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
className="flex-1 min-w-0 h-10 px-3 rounded-xl border border-neutral-200 bg-neutral-50 text-[13px] text-neutral-800 outline-none focus:border-[#2563eb]"
|
className="flex-1 min-w-0 h-10 px-3 rounded-xl border border-neutral-200 bg-neutral-50 text-[13px] text-neutral-800 outline-none focus:border-[#2563eb]"
|
||||||
placeholder={sessionEnded ? '会话已结束' : sessionId ? '输入消息...' : '正在连接...'}
|
placeholder={sessionEnded ? '会话已结束' : sessionId ? '输入消息...' : '正在连接...'}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={e => setInput(e.target.value)}
|
onChange={e => { setInput(e.target.value); emitTyping() }}
|
||||||
|
onPaste={e => {
|
||||||
|
const item = Array.from(e.clipboardData.items).find(i => i.type.startsWith('image/'))
|
||||||
|
if (item) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleImageFile(item.getAsFile())
|
||||||
|
}
|
||||||
|
}}
|
||||||
onKeyDown={e => { if (e.key === 'Enter') sendMessage(input) }}
|
onKeyDown={e => { if (e.key === 'Enter') sendMessage(input) }}
|
||||||
disabled={sending || !sessionId || sessionEnded}
|
disabled={sending || !sessionId || sessionEnded}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user