feat(session): 优化工作台会话分页与筛选
CI / Backend (go vet + test) (push) Failing after 3m18s
CI / Frontend (typecheck + lint + build) (push) Successful in 10m42s

This commit is contained in:
yml2213
2026-11-06 19:05:41 +08:00
parent 93ef9f2040
commit 723989f039
6 changed files with 524 additions and 135 deletions
+199 -87
View File
@@ -4,7 +4,7 @@ import {
CheckCircleOutlined, FileTextOutlined, FlagOutlined, PaperClipOutlined,
SearchOutlined, SendOutlined, SwapOutlined, FilterOutlined,
ExportOutlined, BookOutlined, PictureOutlined, ThunderboltOutlined,
StopOutlined, UserOutlined,
StopOutlined, UserOutlined, LeftOutlined, RightOutlined,
} from '@ant-design/icons'
import EmojiPicker, { insertAtCursor } from '@/components/common/EmojiPicker'
import { ChatImage } from '@/components/common/ImagePreview'
@@ -12,7 +12,7 @@ import MarkdownBody, { stripMarkdown } from '@/components/common/MarkdownBody'
import { useAuth } from '@/stores/auth'
import { OPEN_AGENT_SESSION_EVENT, useAgentNotifications } from '@/stores/notifications'
import {
addSessionNote, claimSession, getCustomer, getCustomerTags, getCustomers, getSession, getSessionMessages, getSessions,
addSessionNote, claimSession, getCustomer, getCustomerTags, getSession, getSessionMessages, getSessions,
getQuickReplies, markSessionRead, sendSessionMessage, suggestQuickReplies, updateCustomer, updateSessionPriority, uploadImage,
useQuickReply as recordQuickReplyUsage,
type Customer, type CustomerContact, type CustomerTag, type Message, type QuickReply,
@@ -26,10 +26,13 @@ import {
import { CustomerInfoField, CustomerTagsEditor } from './dashboard/CustomerFields'
import { BlacklistModal, EndSessionModal, KnowledgePickerModal, QuickReplyPickerModal, TransferModal } from './dashboard/modals'
const SESSION_PAGE_SIZE = 20
const Dashboard = () => {
const { user } = useAuth()
const { setActiveSessionId } = useAgentNotifications()
const [sessions, setSessions] = useState<Session[]>([])
const [selectedSession, setSelectedSession] = useState<Session | null>(null)
const [customers, setCustomers] = useState<Record<number, Customer>>({})
const [loading, setLoading] = useState(true)
const [selectedId, setSelectedId] = useState<number | null>(null)
@@ -37,6 +40,9 @@ const Dashboard = () => {
const [detailLoading, setDetailLoading] = useState(false)
const [messageInput, setMessageInput] = useState('')
const [search, setSearch] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const [sessionPage, setSessionPage] = useState(1)
const [sessionTotal, setSessionTotal] = useState(0)
const [sending, setSending] = useState(false)
const [transferOpen, setTransferOpen] = useState(false)
const [blacklistOpen, setBlacklistOpen] = useState(false)
@@ -79,6 +85,8 @@ const Dashboard = () => {
const fileInputRef = useRef<HTMLInputElement>(null)
const messageInputRef = useRef<HTMLTextAreaElement>(null)
const socketRef = useRef<WebSocket | null>(null)
const listRequestRef = useRef(0)
const detailRequestRef = useRef(0)
const lastTypingAt = useRef(0)
const visitorTypingTimer = useRef<number | null>(null)
/** 当前选中会话,供稳定 WS 闭包读取,避免切会话时重连 */
@@ -114,46 +122,67 @@ const Dashboard = () => {
const isManager = user?.role === 'admin' || user?.role === 'supervisor'
const loadAll = useCallback(async (opts?: { silent?: boolean }) => {
const requestId = ++listRequestRef.current
if (!opts?.silent) setLoading(true)
try {
const [sessionRes, customerRes] = await Promise.all([
getSessions({ page: 1, pageSize: 100 }),
getCustomers({ page: 1, pageSize: 100 }),
])
const sessionRes = await getSessions({
scope: 'workbench',
status: statusFilter === 'all' ? undefined : statusFilter,
priority: priorityFilter === 'all' ? undefined : priorityFilter,
search: debouncedSearch || undefined,
page: sessionPage,
pageSize: SESSION_PAGE_SIZE,
})
if (requestId !== listRequestRef.current) return
const sessionList = Array.isArray(sessionRes.list) ? sessionRes.list : []
const customerList = Array.isArray(customerRes.list) ? customerRes.list : []
const total = Number(sessionRes.total) || 0
const maxPage = Math.max(1, Math.ceil(total / SESSION_PAGE_SIZE))
if (sessionPage > maxPage) {
setSessionPage(maxPage)
return
}
setSessions(sessionList)
setCustomers(Object.fromEntries(customerList.map(customer => [customer.id, customer])))
setSessionTotal(total)
// 默认选中上次会话,若没有则默认选中第一条会话
if (sessionList.length > 0 && initialLoad.current) {
const savedId = localStorage.getItem('kefu_last_session_id')
const matched = savedId ? sessionList.find(s => s.id === Number(savedId)) : null
if (matched) {
setSelectedId(matched.id)
} else {
const fallback = sessionList.find(s => s.status === 'active')
if (initialLoad.current) {
if (sessionList.length > 0) {
const savedId = localStorage.getItem('kefu_last_session_id')
const matched = savedId ? sessionList.find(s => s.id === Number(savedId)) : null
const fallback = matched
|| sessionList.find(s => s.priority === 'urgent')
|| sessionList.find(s => s.status === 'waiting')
|| sessionList[0]
setSelectedId(fallback.id)
setSelectedSession(fallback)
}
initialLoad.current = false
} else if (selectedIdRef.current != null) {
const refreshed = sessionList.find(s => s.id === selectedIdRef.current)
if (refreshed) {
setSelectedSession(previous =>
previous?.id === refreshed.id ? { ...previous, ...refreshed } : refreshed,
)
}
}
} catch {
if (requestId !== listRequestRef.current) return
if (!opts?.silent) antMsg.error('加载会话失败')
if (!opts?.silent) {
setSessions([])
setCustomers({})
setSessionTotal(0)
}
} finally {
if (!opts?.silent) setLoading(false)
if (requestId === listRequestRef.current) setLoading(false)
}
}, [])
}, [debouncedSearch, priorityFilter, sessionPage, statusFilter])
const loadDetail = useCallback(async (id: number, markRead = true, opts?: { silent?: boolean }) => {
const requestId = ++detailRequestRef.current
if (!opts?.silent) setDetailLoading(true)
try {
const response = await getSession(id)
if (requestId !== detailRequestRef.current || selectedIdRef.current !== id) return
const data = response.data
const messages = data.messages || []
setDetail({
@@ -169,6 +198,11 @@ const Dashboard = () => {
if (typeof data.max_seq === 'number') rememberSeq(id, data.max_seq)
// 用详情接口的完整会话字段(含 IP/地区)回填列表,保证顶栏展示准确
if (data.session) {
setSelectedSession(previous => ({
...(previous?.id === id ? previous : data.session),
...data.session,
unread_count: markRead ? 0 : previous?.id === id ? previous.unread_count : 0,
}))
setSessions(previous => previous.map(session =>
session.id === id
? {
@@ -186,14 +220,16 @@ const Dashboard = () => {
}
} else if (markRead) {
setSessions(previous => previous.map(session => session.id === id ? { ...session, unread_count: 0 } : session))
setSelectedSession(previous => previous?.id === id ? { ...previous, unread_count: 0 } : previous)
}
if (markRead) {
await markSessionRead(id)
}
} catch {
if (requestId !== detailRequestRef.current) return
if (!opts?.silent) antMsg.error('加载消息失败')
} finally {
if (!opts?.silent) setDetailLoading(false)
if (requestId === detailRequestRef.current && !opts?.silent) setDetailLoading(false)
}
setTimeout(() => chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 80)
}, [rememberMessagesSeq, rememberSeq])
@@ -231,6 +267,14 @@ const Dashboard = () => {
}
if (latest && selectedIdRef.current === id) {
const preview = latest.type === 'image' ? '[图片]' : stripMarkdown(latest.content)
setSelectedSession(previous => previous?.id === id
? {
...previous,
last_message: preview,
last_message_at: latest!.sent_at,
unread_count: opts?.markRead === false ? previous.unread_count : 0,
}
: previous)
setSessions(previous => previous.map(session =>
session.id === id
? {
@@ -288,6 +332,16 @@ const Dashboard = () => {
unread_count: viewing ? 0 : (session.unread_count || 0) + (msg.sender_type === 'visitor' ? 1 : 0),
}
}))
setSelectedSession(previous => previous?.id === sessionId
? {
...previous,
last_message: preview,
last_message_at: msg.sent_at,
unread_count: selectedIdRef.current === sessionId
? 0
: (previous.unread_count || 0) + (msg.sender_type === 'visitor' ? 1 : 0),
}
: previous)
if (msg.seq > 0) rememberSeq(sessionId, msg.seq)
if (selectedIdRef.current === sessionId) {
setTimeout(() => chatEndRef.current?.scrollIntoView({ behavior: 'smooth' }), 40)
@@ -308,7 +362,10 @@ const Dashboard = () => {
useEffect(() => {
const handleOpenSession = (event: Event) => {
const sessionId = Number((event as CustomEvent<number>).detail)
if (Number.isFinite(sessionId) && sessionId > 0) setSelectedId(sessionId)
if (Number.isFinite(sessionId) && sessionId > 0 && selectedIdRef.current !== sessionId) {
setSelectedSession(null)
setSelectedId(sessionId)
}
}
window.addEventListener(OPEN_AGENT_SESSION_EVENT, handleOpenSession)
return () => window.removeEventListener(OPEN_AGENT_SESSION_EVENT, handleOpenSession)
@@ -320,6 +377,14 @@ const Dashboard = () => {
syncAfterSeqRef.current = syncAfterSeq
}, [loadAll, loadDetail, syncAfterSeq])
useEffect(() => {
const timer = window.setTimeout(() => {
setDebouncedSearch(search.trim())
setSessionPage(1)
}, 300)
return () => window.clearTimeout(timer)
}, [search])
useEffect(() => { void loadAll() }, [loadAll])
useEffect(() => {
@@ -432,16 +497,16 @@ const Dashboard = () => {
last_seen_at?: string | null
visitor_left_at?: string | null
}
setSessions(previous => previous.map(session =>
session.id === sid
? {
...session,
visitor_online: Boolean(presence.visitor_online),
last_seen_at: presence.last_seen_at ?? session.last_seen_at,
visitor_left_at: presence.visitor_left_at ?? null,
}
: session,
))
const patchPresence = (session: Session): Session => session.id === sid
? {
...session,
visitor_online: Boolean(presence.visitor_online),
last_seen_at: presence.last_seen_at ?? session.last_seen_at,
visitor_left_at: presence.visitor_left_at ?? null,
}
: session
setSessions(previous => previous.map(patchPresence))
setSelectedSession(previous => previous ? patchPresence(previous) : previous)
return
}
@@ -462,16 +527,16 @@ const Dashboard = () => {
// 最新轨迹插到最上方
return { ...prev, pageViews: [raw, ...prev.pageViews] }
})
setSessions(previous => previous.map(session =>
session.id === sid
? {
...session,
current_url: raw.url,
current_title: raw.title || session.current_title,
last_seen_at: raw.entered_at || session.last_seen_at,
}
: session,
))
const patchPageView = (session: Session): Session => session.id === sid
? {
...session,
current_url: raw.url,
current_title: raw.title || session.current_title,
last_seen_at: raw.entered_at || session.last_seen_at,
}
: session
setSessions(previous => previous.map(patchPageView))
setSelectedSession(previous => previous ? patchPageView(previous) : previous)
}
}
} catch {
@@ -680,8 +745,23 @@ const Dashboard = () => {
}, 220)
}, [closeSuggest])
const selected = sessions.find(session => session.id === selectedId)
const selectedCustomer = selected ? customers[selected.customer_id] : null
const selected = selectedSession
const selectedCustomer: Customer | null = selected
? customers[selected.customer_id] || {
id: selected.customer_id,
tenant_id: selected.tenant_id,
name: selected.customer_name || `客户${selected.customer_id}`,
phone: '',
email: '',
tags: '',
source: '',
status: 'normal',
conversation_count: 0,
last_contact_at: selected.created_at,
}
: null
const selectedCustomerId = selectedCustomer?.id
const selectedStatus = selected?.status
const visitorOnline = selected ? isVisitorOnline(selected) : false
const canOperate = Boolean(selected && (isManager || selected.agent_id === user?.user_id) && selected.status === 'active')
@@ -693,7 +773,7 @@ const Dashboard = () => {
// 进行中会话:轨迹每 8s 静默拉一次(防 WS 漏推)
useEffect(() => {
if (!selected || selected.status === 'ended' || selected.status === 'archived') return
if (!selectedId || selectedStatus === 'ended' || selectedStatus === 'archived') return
const poll = window.setInterval(() => {
if (selectedIdRef.current != null) {
void loadDetailRef.current(selectedIdRef.current, false, { silent: true })
@@ -702,28 +782,33 @@ const Dashboard = () => {
return () => {
clearInterval(poll)
}
}, [selected?.id, selected?.status])
}, [selectedId, selectedStatus])
useEffect(() => {
if (!selectedCustomer) {
if (!selectedCustomerId) {
setCustomerHistory([])
return
}
getSessions({ page: 1, pageSize: 50 }).then(res => {
let cancelled = false
getSessions({ customer_id: selectedCustomerId, page: 1, pageSize: 6 }).then(res => {
if (cancelled) return
const list = Array.isArray(res.list) ? res.list : []
setCustomerHistory(
list
.filter(s => s.customer_id === selectedCustomer.id && s.id !== selectedId)
.filter(s => s.customer_id === selectedCustomerId && s.id !== selectedId)
.slice(0, 5),
)
}).catch(() => setCustomerHistory([]))
}, [selectedCustomer?.id, selectedId])
}).catch(() => {
if (!cancelled) setCustomerHistory([])
})
return () => { cancelled = true }
}, [selectedCustomerId, selectedId])
// 拉取客户详情中的多条联系方式(输入识别沉淀)
useEffect(() => {
if (!selectedCustomer?.id) return
if (!selectedCustomerId) return
let cancelled = false
getCustomer(selectedCustomer.id)
getCustomer(selectedCustomerId)
.then(res => {
if (cancelled) return
const data = res.data
@@ -739,7 +824,7 @@ const Dashboard = () => {
})
.catch(() => { /* 侧栏增强信息失败不影响会话 */ })
return () => { cancelled = true }
}, [selectedCustomer?.id, selectedId])
}, [selectedCustomerId, selectedId])
const saveCustomerField = useCallback(async (customerId: number, field: CustomerEditableField, value: string) => {
setSavingCustomerField(field)
@@ -791,27 +876,9 @@ const Dashboard = () => {
}, [customers])
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
|| 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 totalPages = Math.max(1, Math.ceil(sessionTotal / SESSION_PAGE_SIZE))
const pageStart = sessionTotal === 0 ? 0 : (sessionPage - 1) * SESSION_PAGE_SIZE + 1
const pageEnd = Math.min(sessionPage * SESSION_PAGE_SIZE, sessionTotal)
const timelineEvents = (detail?.events || []).filter(event =>
event.action === 'transfer'
@@ -1017,16 +1084,16 @@ const Dashboard = () => {
<SearchOutlined className="text-slate-400 text-xs mr-2 shrink-0" />
<input
className="bg-transparent border-none outline-none flex-1 min-w-0 text-xs text-slate-800 placeholder:text-slate-400"
placeholder="搜索访客或会话..."
placeholder="搜索客户、最后消息或会话 ID..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<span
className="inline-flex items-center h-7 px-2 rounded-lg text-xs font-semibold bg-blue-50 text-blue-600 border border-blue-100/80 whitespace-nowrap shrink-0"
title={`当前会话 ${filteredSessions.length} 个`}
title={`共 ${sessionTotal} 个${debouncedSearch || filterActive ? '匹配' : '未结束'}会话`}
>
{filteredSessions.length}
{sessionTotal}
</span>
<Popover
open={filterOpen}
@@ -1046,7 +1113,10 @@ const Dashboard = () => {
<button
key={item.key}
type="button"
onClick={() => setStatusFilter(item.key)}
onClick={() => {
setStatusFilter(item.key)
setSessionPage(1)
}}
className={`px-2.5 py-1 rounded-lg text-xs border transition-all cursor-pointer ${
statusFilter === item.key
? 'bg-blue-50 border-blue-400 text-blue-600 font-medium'
@@ -1068,7 +1138,10 @@ const Dashboard = () => {
<button
key={item.key}
type="button"
onClick={() => setPriorityFilter(item.key)}
onClick={() => {
setPriorityFilter(item.key)
setSessionPage(1)
}}
className={`px-2.5 py-1 rounded-lg text-xs border transition-all cursor-pointer ${
priorityFilter === item.key
? 'bg-blue-50 border-blue-400 text-blue-600 font-medium'
@@ -1084,7 +1157,11 @@ const Dashboard = () => {
<button
type="button"
className="text-xs text-slate-500 hover:text-blue-600 font-medium cursor-pointer border-0 bg-transparent p-0"
onClick={() => { setStatusFilter('all'); setPriorityFilter('all') }}
onClick={() => {
setStatusFilter('all')
setPriorityFilter('all')
setSessionPage(1)
}}
>
清除筛选
</button>
@@ -1117,18 +1194,23 @@ const Dashboard = () => {
</div>
<div className="flex-1 overflow-y-auto p-1.5 space-y-1">
{filteredSessions.length === 0 ? (
<div className="text-center text-xs text-slate-400 py-16">暂无待处理会话</div>
) : filteredSessions.map(session => {
{sessions.length === 0 ? (
<div className="text-center text-xs text-slate-400 py-16">
{debouncedSearch || filterActive ? '没有匹配的会话' : '暂无待处理会话'}
</div>
) : sessions.map(session => {
const customer = customers[session.customer_id]
const name = customer?.name || `客户${session.customer_id}`
const name = session.customer_name || customer?.name || `客户${session.customer_id}`
const meta = listStatusMeta(session)
const selected = selectedId === session.id
return (
<button
key={session.id}
type="button"
onClick={() => setSelectedId(session.id)}
onClick={() => {
setSelectedSession(session)
setSelectedId(session.id)
}}
className={`w-full flex items-stretch text-left rounded-xl transition-all duration-150 relative border cursor-pointer ${
selected
? 'bg-blue-50/80 border-blue-200/80 shadow-xs'
@@ -1191,6 +1273,36 @@ const Dashboard = () => {
)
})}
</div>
{sessionTotal > 0 && (
<div className="shrink-0 h-10 px-3 border-t border-slate-100 flex items-center justify-between text-[11px] text-slate-400">
<span className="tabular-nums">
{pageStart}-{pageEnd} / 共 {sessionTotal}
</span>
<div className="flex items-center gap-1.5">
<button
type="button"
title="上一页"
disabled={sessionPage <= 1}
onClick={() => setSessionPage(page => Math.max(1, page - 1))}
className="w-7 h-7 rounded-lg border border-slate-200 bg-white text-slate-500 flex items-center justify-center cursor-pointer hover:bg-slate-50 hover:text-slate-800 disabled:opacity-35 disabled:cursor-not-allowed"
>
<LeftOutlined className="text-[10px]" />
</button>
<span className="min-w-10 text-center tabular-nums text-slate-500 font-medium">
{sessionPage} / {totalPages}
</span>
<button
type="button"
title="下一页"
disabled={sessionPage >= totalPages}
onClick={() => setSessionPage(page => Math.min(totalPages, page + 1))}
className="w-7 h-7 rounded-lg border border-slate-200 bg-white text-slate-500 flex items-center justify-center cursor-pointer hover:bg-slate-50 hover:text-slate-800 disabled:opacity-35 disabled:cursor-not-allowed"
>
<RightOutlined className="text-[10px]" />
</button>
</div>
</div>
)}
</section>
{/* 主聊天区 */}
@@ -1927,20 +2039,20 @@ const Dashboard = () => {
<TransferModal
open={transferOpen}
session={selected}
session={selected || undefined}
excludeAgentId={user?.user_id}
onClose={() => setTransferOpen(false)}
onDone={clearAndReload}
/>
<EndSessionModal
open={endingOpen}
session={selected}
session={selected || undefined}
onClose={() => setEndingOpen(false)}
onDone={clearAndReload}
/>
<BlacklistModal
open={blacklistOpen}
session={selected}
session={selected || undefined}
onClose={() => setBlacklistOpen(false)}
onDone={handleBlacklistDone}
/>
+4
View File
@@ -259,10 +259,12 @@ export const updateMyStatus = (status: AgentPresence) =>
// Sessions
export const getSessions = (params?: {
scope?: string
status?: string
priority?: string
agent_id?: number | string
channel_id?: number | string
customer_id?: number | string
search?: string
from?: string
to?: string
@@ -270,10 +272,12 @@ export const getSessions = (params?: {
pageSize?: number
}) => {
const search = new URLSearchParams()
if (params?.scope) search.set('scope', params.scope)
if (params?.status) search.set('status', params.status)
if (params?.priority) search.set('priority', params.priority)
if (params?.agent_id != null && params.agent_id !== '') search.set('agent_id', String(params.agent_id))
if (params?.channel_id != null && params.channel_id !== '') search.set('channel_id', String(params.channel_id))
if (params?.customer_id != null && params.customer_id !== '') search.set('customer_id', String(params.customer_id))
if (params?.search) search.set('search', params.search)
if (params?.from) search.set('from', params.from)
if (params?.to) search.set('to', params.to)