补全 P0:筛选、访客图片与双向输入状态
- 工作台会话列表支持状态/紧急筛选 - 访客 Widget 支持 jpg/png/gif 图片预览发送 - WebSocket 支持访客输入中通知客服,客服输入中通知访客 - 工作台聊天区展示「访客正在输入」动画
This commit is contained in:
@@ -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<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 chatEndRef = useRef<HTMLDivElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const socketRef = useRef<WebSocket | null>(null)
|
||||
const lastTypingAt = useRef(0)
|
||||
const visitorTypingTimer = useRef<number | null>(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)}
|
||||
/>
|
||||
</div>
|
||||
<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"
|
||||
title="筛选"
|
||||
<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
|
||||
type="button"
|
||||
className="text-xs text-neutral-500 hover:text-neutral-700"
|
||||
onClick={() => { setStatusFilter('all'); setPriorityFilter('all') }}
|
||||
>
|
||||
清除筛选
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<FilterOutlined className="text-xs" />
|
||||
</button>
|
||||
<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="筛选"
|
||||
>
|
||||
<FilterOutlined className="text-xs" />
|
||||
{filterActive && <span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-[#2563eb]" />}
|
||||
</button>
|
||||
</Popover>
|
||||
</div>
|
||||
<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]">
|
||||
@@ -581,6 +677,27 @@ const Dashboard = () => {
|
||||
{detail?.messages.length === 0 && (
|
||||
<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} />
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user