"use client" import { CheckCheckIcon, MessageCircleMoreIcon, MoreHorizontalIcon, RefreshCwIcon, SearchIcon, } from "lucide-react" import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from "react" import { toast } from "sonner" import { ConversationCloseDialog } from "@/components/conversation-actions/close-dialog" import { ConversationTransferDialog } from "@/components/conversation-actions/transfer-dialog" import { ListPagination } from "@/components/list-pagination" import { OptionCombobox, type ComboboxOption, } from "@/components/option-combobox" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { ButtonGroup } from "@/components/ui/button-group" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { Input } from "@/components/ui/input" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table" import { createAdminWebSocketUrl, dispatchConversation, fetchAgentProfilesAll, fetchAgentTeamsAll, fetchConversationDetail, fetchConversationMessages, fetchConversations, fetchTagsAll, markConversationRead, type AdminAgentProfile, type AdminAgentTeam, type AdminConversation, type AdminConversationDetail, type AdminMessage, type PageResult, type TagTree, } from "@/lib/api/admin" import { formatDateTime } from "@/lib/utils" import { ConversationDetailDialog } from "./_components/detail" const RECONNECT_BASE_DELAY = 2000 const RECONNECT_MAX_DELAY = 30000 const statusOptions = [ { value: "all", label: "全部状态" }, { value: "1", label: "待接入" }, { value: "2", label: "处理中" }, { value: "3", label: "已关闭" }, { value: "4", label: "已归档" }, ] as const function getStatusMeta(status: number) { switch (status) { case 1: return { label: "待接入", variant: "outline" as const } case 2: return { label: "处理中", variant: "secondary" as const } case 3: return { label: "已关闭", variant: "outline" as const } case 4: return { label: "已归档", variant: "outline" as const } default: return { label: "未知", variant: "outline" as const } } } function getServiceModeLabel(mode: number) { switch (mode) { case 1: return "AI 接待" case 2: return "人工接待" case 3: return "AI 优先" default: return "未定义" } } function getStatusLabel(value: string) { return statusOptions.find((item) => item.value === value)?.label ?? "全部状态" } function buildTagOptions( nodes: TagTree[], parentPath = "" ): ComboboxOption[] { const result: ComboboxOption[] = [] nodes.forEach((item) => { const currentPath = parentPath ? `${parentPath}/${item.name}` : item.name result.push({ value: String(item.id), label: currentPath, }) if (item.children.length > 0) { result.push(...buildTagOptions(item.children, currentPath)) } }) return result } export default function DashboardConversationsPage() { const [keywordInput, setKeywordInput] = useState("") const [statusFilterInput, setStatusFilterInput] = useState("all") const [tagFilterInput, setTagFilterInput] = useState("0") const [assigneeFilterInput, setAssigneeFilterInput] = useState("0") const [agentTeamFilterInput, setAgentTeamFilterInput] = useState("0") const [keyword, setKeyword] = useState("") const [statusFilter, setStatusFilter] = useState("all") const [tagFilter, setTagFilter] = useState("0") const [assigneeFilter, setAssigneeFilter] = useState("0") const [agentTeamFilter, setAgentTeamFilter] = useState("0") const [page, setPage] = useState(1) const [limit, setLimit] = useState(20) const [tagOptions, setTagOptions] = useState([ { value: "0", label: "全部标签" }, ]) const [assigneeOptions, setAssigneeOptions] = useState([ { value: "0", label: "全部指派人" }, ]) const [agentTeamOptions, setAgentTeamOptions] = useState([ { value: "0", label: "全部客服组" }, ]) const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) const [detailLoading, setDetailLoading] = useState(false) const [actionLoadingId, setActionLoadingId] = useState(null) const [detailOpen, setDetailOpen] = useState(false) const [detailItem, setDetailItem] = useState(null) const [detailData, setDetailData] = useState(null) const [detailMessages, setDetailMessages] = useState([]) const [detailMessagesNextCursor, setDetailMessagesNextCursor] = useState("") const [detailMessagesHasMore, setDetailMessagesHasMore] = useState(false) const [detailMessagesLoadingMore, setDetailMessagesLoadingMore] = useState(false) const [assignOpen, setAssignOpen] = useState(false) const [assignItem, setAssignItem] = useState(null) const [closeOpen, setCloseOpen] = useState(false) const [closeItem, setCloseItem] = useState(null) const [transferOpen, setTransferOpen] = useState(false) const [transferItem, setTransferItem] = useState(null) const [result, setResult] = useState>({ results: [], page: { page: 1, limit: 20, total: 0 }, }) const websocketRef = useRef(null) const reconnectTimerRef = useRef(null) const pingTimerRef = useRef(null) const reconnectAttemptRef = useRef(0) const detailItemRef = useRef(null) const subscribedConversationIdRef = useRef(null) const loadConversations = useCallback(async () => { setLoading(true) try { const data = await fetchConversations({ keyword: keyword.trim() || undefined, status: statusFilter === "all" ? undefined : statusFilter, tagId: tagFilter === "0" ? undefined : tagFilter, currentAssigneeId: assigneeFilter === "0" ? undefined : assigneeFilter, agentTeamId: agentTeamFilter === "0" ? undefined : agentTeamFilter, page, limit, }) setResult(data) } catch (error) { toast.error(error instanceof Error ? error.message : "加载会话列表失败") } finally { setLoading(false) } }, [keyword, limit, page, statusFilter, tagFilter, assigneeFilter, agentTeamFilter]) useEffect(() => { let cancelled = false async function loadFilterOptions() { try { const [tagData, assigneeData, teamData] = await Promise.all([ fetchTagsAll(), fetchAgentProfilesAll(), fetchAgentTeamsAll(), ]) if (!cancelled) { setTagOptions([ { value: "0", label: "全部标签" }, ...buildTagOptions(tagData), ]) setAssigneeOptions([ { value: "0", label: "全部指派人" }, ...assigneeData.map((item: AdminAgentProfile) => ({ value: String(item.userId), label: item.displayName || item.nickname || item.username || `#${item.userId}`, })), ]) setAgentTeamOptions([ { value: "0", label: "全部客服组" }, ...teamData.map((item: AdminAgentTeam) => ({ value: String(item.id), label: item.name, })), ]) } } catch (error) { if (!cancelled) { toast.error(error instanceof Error ? error.message : "加载筛选项失败") } } } void loadFilterOptions() return () => { cancelled = true } }, []) useEffect(() => { detailItemRef.current = detailItem }, [detailItem]) useEffect(() => { void loadConversations() }, [loadConversations]) useEffect(() => { let cancelled = false const clearTimers = () => { if (reconnectTimerRef.current) { window.clearTimeout(reconnectTimerRef.current) reconnectTimerRef.current = null } if (pingTimerRef.current) { window.clearInterval(pingTimerRef.current) pingTimerRef.current = null } } const scheduleReconnect = () => { if (cancelled || reconnectTimerRef.current) { return } const delay = Math.min( RECONNECT_BASE_DELAY * 2 ** reconnectAttemptRef.current, RECONNECT_MAX_DELAY ) reconnectTimerRef.current = window.setTimeout(() => { reconnectTimerRef.current = null reconnectAttemptRef.current += 1 connect() }, delay) } const connect = () => { if (cancelled) { return } let socket: WebSocket try { socket = new WebSocket(createAdminWebSocketUrl()) } catch (error) { toast.error(error instanceof Error ? error.message : "连接实时服务失败") scheduleReconnect() return } websocketRef.current = socket socket.onopen = () => { reconnectAttemptRef.current = 0 if (pingTimerRef.current) { window.clearInterval(pingTimerRef.current) } pingTimerRef.current = window.setInterval(() => { if (socket.readyState === WebSocket.OPEN) { socket.send(JSON.stringify({ type: "ping" })) } }, 20000) const conversationId = detailItemRef.current?.id if (conversationId) { socket.send( JSON.stringify({ type: "subscribe", topics: [`conversation:${conversationId}`], }) ) subscribedConversationIdRef.current = conversationId } else { subscribedConversationIdRef.current = null } } socket.onmessage = (event) => { try { const payload = JSON.parse(event.data) as { eventId?: string type?: string data?: { conversationId?: number } } const eventType = payload.type ?? "" const conversationId = payload.data?.conversationId ?? 0 const eventId = payload.eventId?.trim() ?? "" if ( eventType === "" || eventType === "connected" || eventType === "pong" || eventType === "subscribed" || eventType === "unsubscribed" ) { return } if (eventId && socket.readyState === WebSocket.OPEN) { socket.send(JSON.stringify({ type: "ack", eventId })) } void loadConversations() const currentDetail = detailItemRef.current if (conversationId > 0 && currentDetail?.id === conversationId) { void loadDetail(currentDetail) } } catch { // ignore invalid ws payload } } socket.onclose = () => { if (pingTimerRef.current) { window.clearInterval(pingTimerRef.current) pingTimerRef.current = null } if (websocketRef.current === socket) { websocketRef.current = null } subscribedConversationIdRef.current = null scheduleReconnect() } } connect() return () => { cancelled = true clearTimers() reconnectAttemptRef.current = 0 const socket = websocketRef.current websocketRef.current = null if (socket) { socket.close() } subscribedConversationIdRef.current = null } }, [loadConversations]) useEffect(() => { const socket = websocketRef.current if (!socket || socket.readyState !== WebSocket.OPEN) { return } const previousConversationId = subscribedConversationIdRef.current const nextConversationId = detailOpen ? detailItem?.id ?? null : null if (previousConversationId && previousConversationId !== nextConversationId) { socket.send( JSON.stringify({ type: "unsubscribe", topics: [`conversation:${previousConversationId}`], }) ) } if (nextConversationId && nextConversationId !== previousConversationId) { socket.send( JSON.stringify({ type: "subscribe", topics: [`conversation:${nextConversationId}`], }) ) subscribedConversationIdRef.current = nextConversationId return } if (!nextConversationId) { subscribedConversationIdRef.current = null } }, [detailItem, detailOpen]) function handleStatusFilterChange(value: string | null) { setStatusFilterInput(value ?? "all") } function applyFilters() { setKeyword(keywordInput) setStatusFilter(statusFilterInput) setTagFilter(tagFilterInput) setAssigneeFilter(assigneeFilterInput) setAgentTeamFilter(agentTeamFilterInput) setPage(1) } function handleFilterKeyDown(event: KeyboardEvent) { if (event.key !== "Enter") { return } event.preventDefault() applyFilters() } function handlePageChange(nextPage: number) { if (nextPage < 1 || nextPage === page) { return } setPage(nextPage) } function handleLimitChange(nextLimit: number) { if (nextLimit <= 0 || nextLimit === limit) { return } setLimit(nextLimit) setPage(1) } async function loadDetail(item: AdminConversation) { setDetailLoading(true) setDetailMessagesNextCursor("") setDetailMessagesHasMore(false) try { const [detail, messages] = await Promise.all([ fetchConversationDetail(item.id), fetchConversationMessages({ conversationId: item.id, limit: 20 }), ]) setDetailData(detail) setDetailMessages(messages.results) setDetailMessagesNextCursor(messages.cursor ?? "") setDetailMessagesHasMore(Boolean(messages.hasMore)) } catch (error) { toast.error(error instanceof Error ? error.message : "加载会话详情失败") } finally { setDetailLoading(false) } } const loadMoreDetailMessages = useCallback(async () => { if (!detailItem || detailMessagesLoadingMore || !detailMessagesHasMore) { return } const cursor = Number.parseInt(detailMessagesNextCursor, 10) if (!detailMessagesNextCursor.trim() || !Number.isFinite(cursor) || cursor <= 0) { return } setDetailMessagesLoadingMore(true) try { const page = await fetchConversationMessages({ conversationId: detailItem.id, cursor, limit: 20, }) setDetailMessages((prev) => [...page.results, ...prev]) setDetailMessagesNextCursor(page.cursor ?? "") setDetailMessagesHasMore(Boolean(page.hasMore)) } catch (error) { toast.error(error instanceof Error ? error.message : "加载更多消息失败") } finally { setDetailMessagesLoadingMore(false) } }, [ detailItem, detailMessagesHasMore, detailMessagesLoadingMore, detailMessagesNextCursor, ]) async function openDetail(item: AdminConversation) { setDetailItem(item) setDetailData(null) setDetailMessages([]) setDetailMessagesNextCursor("") setDetailMessagesHasMore(false) setDetailMessagesLoadingMore(false) setDetailOpen(true) await loadDetail(item) } function handleDetailOpenChange(open: boolean) { if (actionLoadingId) { return } if (!open) { setDetailOpen(false) setDetailItem(null) setDetailData(null) setDetailMessages([]) setDetailMessagesNextCursor("") setDetailMessagesHasMore(false) setDetailMessagesLoadingMore(false) return } setDetailOpen(true) } function openAssign(item: AdminConversation) { setAssignItem(item) setAssignOpen(true) } function handleAssignOpenChange(open: boolean) { if (actionLoadingId) { return } if (!open) { setAssignOpen(false) setAssignItem(null) return } setAssignOpen(true) } function openTransfer(item: AdminConversation) { setTransferItem(item) setTransferOpen(true) } function openClose(item: AdminConversation) { setCloseItem(item) setCloseOpen(true) } function handleCloseOpenChange(open: boolean) { if (actionLoadingId) { return } if (!open) { setCloseOpen(false) setCloseItem(null) return } setCloseOpen(true) } function handleTransferOpenChange(open: boolean) { if (actionLoadingId) { return } if (!open) { setTransferOpen(false) setTransferItem(null) return } setTransferOpen(true) } async function refreshDetail() { if (!detailOpen || !detailItem) { return } await loadDetail(detailItem) } async function handleRefresh() { setRefreshing(true) try { await loadConversations() await refreshDetail() } finally { setRefreshing(false) } } async function handleRead(item: AdminConversation) { setActionLoadingId(item.id) try { await markConversationRead(item.id) toast.success(`已标记已读:${item.subject || `#${item.id}`}`) await loadConversations() if (detailItem?.id === item.id) { await refreshDetail() } } catch (error) { toast.error(error instanceof Error ? error.message : "标记已读失败") } finally { setActionLoadingId(null) } } async function handleDispatch(item: AdminConversation) { setActionLoadingId(item.id) try { await dispatchConversation(item.id) toast.success(`已触发自动分配:${item.subject || `#${item.id}`}`) await loadConversations() if (detailItem?.id === item.id) { await refreshDetail() } } catch (error) { toast.error(error instanceof Error ? error.message : "自动分配失败") } finally { setActionLoadingId(null) } } async function handleConversationChanged(conversationId: number) { await loadConversations() if (detailItem?.id === conversationId) { await refreshDetail() } } return ( <>
setKeywordInput(event.target.value)} onKeyDown={handleFilterKeyDown} placeholder="按主题或摘要筛选" className="pl-9" />
会话信息 状态 接待模式 当前客服 未读 最后活跃 操作 {result.results.map((item) => { const statusMeta = getStatusMeta(item.status) return (
{item.subject || `会话 #${item.id}`}
渠道:{item.externalSource || "-"}
{item.lastMessageSummary || "暂无最新消息摘要"}
{statusMeta.label} {getServiceModeLabel(item.serviceMode)} {item.currentAssigneeName || "-"} 客服 {item.agentUnreadCount} / 用户 {item.customerUnreadCount} {formatDateTime(item.lastMessageAt)} } aria-label={`更多操作 ${item.subject || item.id}`} > openAssign(item)} disabled={actionLoadingId === item.id || item.status !== 1} > {actionLoadingId === item.id ? "处理中..." : "分配会话"} void handleDispatch(item)} disabled={actionLoadingId === item.id || item.status !== 1} > {actionLoadingId === item.id ? "处理中..." : "重试分配"} void handleRead(item)} disabled={actionLoadingId === item.id} > {actionLoadingId === item.id ? "处理中..." : "标记已读"} openTransfer(item)} disabled={actionLoadingId === item.id || item.status !== 2} > {actionLoadingId === item.id ? "处理中..." : "转接会话"} {item.status !== 3 ? ( openClose(item)} disabled={actionLoadingId === item.id} > {actionLoadingId === item.id ? "处理中..." : "关闭会话"} ) : null}
) })} {!loading && result.results.length === 0 ? ( 没有匹配的会话记录 ) : null}
{ if (!detailItem) { return } openAssign(detailItem) }} onDispatch={async () => { if (!detailItem) { return } await handleDispatch(detailItem) }} onOpenTransfer={() => { if (!detailItem) { return } openTransfer(detailItem) }} onRead={async () => { if (!detailItem) { return } await handleRead(detailItem) }} onOpenClose={() => { if (!detailItem) { return } openClose(detailItem) }} /> { const conversationId = closeItem?.id setCloseOpen(false) setCloseItem(null) if (conversationId) { await handleConversationChanged(conversationId) } }} /> { const conversationId = assignItem?.id setAssignOpen(false) setAssignItem(null) if (conversationId) { await handleConversationChanged(conversationId) } }} /> { const conversationId = transferItem?.id setTransferOpen(false) setTransferItem(null) if (conversationId) { await handleConversationChanged(conversationId) } }} /> ) }