调整目录

This commit is contained in:
mlogclub
2026-04-24 12:01:30 +08:00
parent 7889ecb0d2
commit a9275c2d4a
209 changed files with 0 additions and 0 deletions
@@ -0,0 +1,560 @@
"use client";
import { CheckCheckIcon, EyeIcon, MessageCircleMoreIcon } from "lucide-react";
import Image from "next/image";
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
} from "react";
import { ImMessageHTML } from "@/components/im-message-html";
import { useImageLightbox } from "@/components/image-lightbox";
import { ProjectDialog } from "@/components/project-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import {
type AdminConversation,
type AdminConversationDetail,
type AdminMessage,
} from "@/lib/api/admin";
import {
parseMessageAssetPayload,
renderIMMessageHTML,
} from "@/lib/im-message";
import { formatDateTime } from "@/lib/utils";
type ConversationDetailDialogProps = {
open: boolean;
loading: boolean;
saving: boolean;
item: AdminConversation | null;
detail: AdminConversationDetail | null;
messages: AdminMessage[];
/** 是否还有更早消息(cursor 分页) */
messagesHasMore?: boolean;
loadingMoreMessages?: boolean;
onLoadMoreMessages?: () => void | Promise<void>;
onOpenChange: (open: boolean) => void;
onOpenAssign: () => void;
onDispatch: () => Promise<void>;
onOpenTransfer: () => void;
onRead: () => Promise<void>;
onOpenClose: () => void;
};
function getStatusMeta(status: number) {
switch (status) {
case 1:
return { label: "AI接待中", variant: "secondary" as const };
case 2:
return { label: "待接入", variant: "outline" as const };
case 3:
return { label: "处理中", variant: "secondary" 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 getSenderLabel(message: AdminMessage) {
switch (message.senderType) {
case "agent":
return message.senderName || "客服";
case "customer":
return message.senderName || "用户";
case "ai":
return "AI";
case "system":
return "系统";
default:
return message.senderType;
}
}
function getMessageContent(message: AdminMessage) {
return message.content || message.payload || "-";
}
function getImageMessageUrl(message: AdminMessage) {
return parseMessageAssetPayload(message.payload)?.url || "";
}
function getParticipantIdentity(
participant: NonNullable<AdminConversationDetail["participants"]>[number],
) {
return participant.participantId || participant.externalParticipantId || "-";
}
function getMessageLayout(message: AdminMessage) {
if (message.senderType === "customer") {
return {
rowClassName: "justify-start",
bubbleClassName: "bg-muted text-foreground border-border",
metaClassName: "text-left",
};
}
if (message.senderType === "system") {
return {
rowClassName: "justify-center",
bubbleClassName:
"bg-muted/60 text-muted-foreground border-dashed border-border",
metaClassName: "text-center",
};
}
if (message.senderType === "ai") {
return {
rowClassName: "justify-end",
bubbleClassName: "bg-primary/10 text-foreground border-primary/20",
metaClassName: "text-right",
};
}
return {
rowClassName: "justify-end",
bubbleClassName: "bg-primary text-primary-foreground border-primary",
metaClassName: "text-right",
};
}
export function ConversationDetailDialog({
open,
loading,
saving,
item,
detail,
messages,
messagesHasMore = false,
loadingMoreMessages = false,
onLoadMoreMessages,
onOpenChange,
onOpenAssign,
onDispatch,
onOpenTransfer,
onRead,
onOpenClose,
}: ConversationDetailDialogProps) {
const currentConversation = detail ?? item;
const isClosedConversation = currentConversation?.status === 4;
const isPendingConversation = currentConversation?.status === 2;
const statusMeta = currentConversation
? getStatusMeta(currentConversation.status)
: null;
const messageBottomRef = useRef<HTMLDivElement | null>(null);
const messagesScrollRootRef = useRef<HTMLDivElement | null>(null);
const loadMoreSentinelRef = useRef<HTMLDivElement | null>(null);
const pendingScrollAnchorRef = useRef<{
scrollHeight: number;
scrollTop: number;
} | null>(null);
const prevLoadingMoreRef = useRef(false);
const { open: openImageLightbox, close: closeImageLightbox } =
useImageLightbox();
const getMessagesViewport = useCallback((): HTMLElement | null => {
return (
messagesScrollRootRef.current?.querySelector(
'[data-slot="scroll-area-viewport"]',
) ?? null
);
}, []);
useEffect(() => {
if (!open) {
closeImageLightbox();
return;
}
if (loading) {
return;
}
const bottom = messageBottomRef.current;
if (!bottom) {
return;
}
bottom.scrollIntoView({ block: "end", behavior: "smooth" });
}, [open, loading, closeImageLightbox]);
useLayoutEffect(() => {
const wasLoading = prevLoadingMoreRef.current;
prevLoadingMoreRef.current = loadingMoreMessages;
if (wasLoading && !loadingMoreMessages && pendingScrollAnchorRef.current) {
const vp = getMessagesViewport();
const anchor = pendingScrollAnchorRef.current;
pendingScrollAnchorRef.current = null;
if (vp && anchor) {
const delta = vp.scrollHeight - anchor.scrollHeight;
vp.scrollTop = anchor.scrollTop + delta;
}
}
}, [loadingMoreMessages, messages, getMessagesViewport]);
useEffect(() => {
if (!open || loading || !messagesHasMore || !onLoadMoreMessages) {
return;
}
const root = getMessagesViewport();
const sentinel = loadMoreSentinelRef.current;
if (!root || !sentinel) {
return;
}
const observer = new IntersectionObserver(
(entries) => {
const hit = entries.some((e) => e.isIntersecting);
if (!hit || loadingMoreMessages) {
return;
}
const vp = getMessagesViewport();
if (vp) {
pendingScrollAnchorRef.current = {
scrollHeight: vp.scrollHeight,
scrollTop: vp.scrollTop,
};
}
void onLoadMoreMessages();
},
{ root, rootMargin: "120px 0px 0px 0px", threshold: 0 },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [
open,
loading,
messagesHasMore,
loadingMoreMessages,
messages.length,
onLoadMoreMessages,
getMessagesViewport,
]);
return (
<ProjectDialog
open={open}
onOpenChange={onOpenChange}
title={
<div className="flex items-center gap-3">
<span>{currentConversation?.subject || "会话详情"}</span>
<div>
{statusMeta ? (
<Badge variant={statusMeta.variant}>{statusMeta.label}</Badge>
) : null}
</div>
</div>
}
size="xl"
// allowFullscreen
defaultFullscreen
bodyScrollable={false}
bodyClassName="flex min-h-0 flex-1 flex-col overflow-hidden p-0"
contentClassName="h-[calc(100vh-40px)] max-h-[calc(100vh-40px)]"
footer={
<div className="flex w-full flex-wrap items-center justify-between gap-3">
<div className="text-sm text-muted-foreground">
{currentConversation
? `最后活跃:${formatDateTime(currentConversation.lastMessageAt)}`
: "暂无会话信息"}
</div>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
onClick={onOpenAssign}
disabled={saving || !currentConversation || currentConversation.status !== 2}
>
<MessageCircleMoreIcon />
{saving ? "处理中..." : "分配会话"}
</Button>
<Button
variant="outline"
onClick={() => void onDispatch()}
disabled={
saving || !currentConversation || !isPendingConversation
}
>
<MessageCircleMoreIcon />
{saving ? "处理中..." : "重试分配"}
</Button>
<Button
variant="outline"
onClick={() => void onRead()}
disabled={saving || !currentConversation}
>
<CheckCheckIcon />
{saving ? "处理中..." : "标记已读"}
</Button>
<Button
type="button"
variant="outline"
onClick={onOpenTransfer}
disabled={saving || !currentConversation || currentConversation.status !== 3}
>
<MessageCircleMoreIcon />
{saving ? "处理中..." : "转接会话"}
</Button>
{!isClosedConversation ? (
<Button
variant="outline"
onClick={onOpenClose}
disabled={saving || !currentConversation}
>
<EyeIcon />
{saving ? "处理中..." : "关闭会话"}
</Button>
) : null}
</div>
</div>
}
>
{loading ? (
<div className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground">
...
</div>
) : currentConversation ? (
<div className="flex min-h-0 flex-1 flex-row overflow-hidden border-t">
<aside className="flex w-90 h-full shrink-0 flex-col overflow-hidden bg-muted/20 border-r border-b-0">
<div className="space-y-4 p-6">
<div className="grid grid-cols-2 gap-3 text-sm">
<InfoItem
label="接待模式"
value={getServiceModeLabel(currentConversation.serviceMode)}
/>
<InfoItem
label="当前客服"
value={currentConversation.currentAssigneeName || "-"}
/>
<InfoItem
label="渠道类型"
value={currentConversation.externalSource || "-"}
/>
<InfoItem
label="客服未读"
value={`${currentConversation.agentUnreadCount}`}
/>
<InfoItem
label="用户未读"
value={`${currentConversation.customerUnreadCount}`}
/>
<InfoItem
label="最后活跃"
value={formatDateTime(currentConversation.lastMessageAt)}
fullWidth
/>
<InfoItem
label="关闭时间"
value={formatDateTime(currentConversation.closedAt)}
fullWidth
/>
</div>
</div>
<Separator />
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-4 p-6">
<section className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-medium"></p>
<span className="text-xs text-muted-foreground">
{detail?.participants?.length ?? 0}
</span>
</div>
{detail?.participants?.length ? (
<div className="space-y-3">
{detail.participants.map((participant) => (
<div
key={participant.id}
className="rounded-lg border bg-background p-3"
>
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium">
{participant.participantType || "-"}
</span>
</div>
<div className="mt-1 text-sm text-muted-foreground">
{getParticipantIdentity(participant)}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{formatDateTime(participant.joinedAt)}
</div>
</div>
))}
</div>
) : (
<div className="rounded-lg border border-dashed bg-background p-4 text-sm text-muted-foreground">
</div>
)}
</section>
</div>
</ScrollArea>
</aside>
<section className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background">
{/* <div className="flex items-center justify-between border-b px-6 py-4">
<div>
<p className="text-sm font-medium">聊天记录</p>
</div>
<div className="text-xs text-muted-foreground">
消息数:{messages.length}
{messagesHasMore ? " · 向上滑可加载更早" : ""}
</div>
</div> */}
<div ref={messagesScrollRootRef} className="min-h-0 flex-1">
<ScrollArea className="h-full min-h-0 bg-muted/10">
<div className="space-y-4 px-6 py-5">
{messagesHasMore ? (
<div
ref={loadMoreSentinelRef}
className="flex min-h-8 flex-col items-center justify-center py-2 text-xs text-muted-foreground"
>
{loadingMoreMessages
? "正在加载更早的消息…"
: "继续上滑加载更早消息"}
</div>
) : null}
{messages.length ? (
messages.map((message) => {
const layout = getMessageLayout(message);
const isRecalled =
Boolean(message.recalledAt) || message.sendStatus === 6;
const isHtmlMessage =
!isRecalled &&
(message.messageType === "html" ||
message.messageType === "attachment");
const isImageMessage = !isRecalled && message.messageType === "image";
return (
<div
key={message.id}
className={`flex ${layout.rowClassName}`}
>
<div className="max-w-[85%] space-y-2">
<div
className={`text-xs text-muted-foreground ${layout.metaClassName}`}
>
<span>{getSenderLabel(message)}</span>
<span className="mx-2">·</span>
<span>{formatDateTime(message.sentAt)}</span>
{isRecalled ? (
<>
<span className="mx-2">·</span>
<span></span>
</>
) : null}
</div>
<div
className={`rounded-2xl border px-4 py-3 text-sm leading-6 ${layout.bubbleClassName}`}
>
{isRecalled ? (
<div className="text-muted-foreground"></div>
) : isHtmlMessage ? (
<ImMessageHTML
html={renderIMMessageHTML(message)}
className="[&_a]:underline [&_img]:max-w-full [&_img]:cursor-zoom-in"
onImageClick={openImageLightbox}
/>
) : isImageMessage ? (
<MessageImage
src={getImageMessageUrl(message)}
alt={getMessageContent(message)}
onPreview={openImageLightbox}
/>
) : (
<div className="whitespace-pre-wrap break-words">
{getMessageContent(message)}
</div>
)}
</div>
<div
className={`text-xs text-muted-foreground ${layout.metaClassName}`}
>
{message.agentRead ? "已读" : "未读"} / {" "}
{message.customerRead ? "已读" : "未读"}
</div>
</div>
</div>
);
})
) : (
<div className="flex h-full min-h-80 items-center justify-center rounded-xl border border-dashed bg-background text-sm text-muted-foreground">
</div>
)}
<div ref={messageBottomRef} />
</div>
</ScrollArea>
</div>
</section>
</div>
) : (
<div className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground">
</div>
)}
</ProjectDialog>
);
}
type InfoItemProps = {
label: string;
value: string;
fullWidth?: boolean;
};
function InfoItem({ label, value, fullWidth = false }: InfoItemProps) {
return (
<div className={fullWidth ? "col-span-2" : undefined}>
<p className="text-xs text-muted-foreground">{label}</p>
<p className="mt-1 break-all font-medium">{value || "-"}</p>
</div>
);
}
type MessageImageProps = {
src: string;
alt: string;
onPreview: (src: string, alt?: string) => void;
};
function MessageImage({ src, alt, onPreview }: MessageImageProps) {
if (!src) {
return (
<div className="text-sm whitespace-pre-wrap break-words">
{alt || "[图片]"}
</div>
);
}
return (
<button
type="button"
className="block cursor-zoom-in"
onClick={() => onPreview(src, alt)}
>
<Image
src={src}
alt={alt || "消息图片"}
width={480}
height={360}
className="max-h-64 w-auto max-w-full rounded-md object-contain"
unoptimized
/>
</button>
);
}
@@ -0,0 +1,913 @@
"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: "AI接待中" },
{ value: "2", label: "待接入" },
{ value: "3", label: "处理中" },
{ value: "4", label: "已关闭" },
] as const
function getStatusMeta(status: number) {
switch (status) {
case 1:
return { label: "AI接待中", variant: "secondary" as const }
case 2:
return { label: "待接入", variant: "outline" as const }
case 3:
return { label: "处理中", variant: "secondary" 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<ComboboxOption[]>([
{ value: "0", label: "全部标签" },
])
const [assigneeOptions, setAssigneeOptions] = useState<ComboboxOption[]>([
{ value: "0", label: "全部指派人" },
])
const [agentTeamOptions, setAgentTeamOptions] = useState<ComboboxOption[]>([
{ value: "0", label: "全部客服组" },
])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [detailLoading, setDetailLoading] = useState(false)
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const [detailItem, setDetailItem] = useState<AdminConversation | null>(null)
const [detailData, setDetailData] = useState<AdminConversationDetail | null>(null)
const [detailMessages, setDetailMessages] = useState<AdminMessage[]>([])
const [detailMessagesNextCursor, setDetailMessagesNextCursor] = useState("")
const [detailMessagesHasMore, setDetailMessagesHasMore] = useState(false)
const [detailMessagesLoadingMore, setDetailMessagesLoadingMore] = useState(false)
const [assignOpen, setAssignOpen] = useState(false)
const [assignItem, setAssignItem] = useState<AdminConversation | null>(null)
const [closeOpen, setCloseOpen] = useState(false)
const [closeItem, setCloseItem] = useState<AdminConversation | null>(null)
const [transferOpen, setTransferOpen] = useState(false)
const [transferItem, setTransferItem] = useState<AdminConversation | null>(null)
const [result, setResult] = useState<PageResult<AdminConversation>>({
results: [],
page: { page: 1, limit: 20, total: 0 },
})
const websocketRef = useRef<WebSocket | null>(null)
const reconnectTimerRef = useRef<number | null>(null)
const pingTimerRef = useRef<number | null>(null)
const reconnectAttemptRef = useRef(0)
const detailItemRef = useRef<AdminConversation | null>(null)
const subscribedConversationIdRef = useRef<number | null>(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<HTMLInputElement>) {
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 (
<>
<div className="flex flex-1 flex-col gap-6 p-4 lg:p-6">
<div className="flex flex-col gap-2 xl:flex-row xl:items-center xl:justify-end">
<div className="relative min-w-72">
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={keywordInput}
onChange={(event) => setKeywordInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
placeholder="按主题或摘要筛选"
className="pl-9"
/>
</div>
<Select value={statusFilterInput} onValueChange={handleStatusFilterChange}>
<SelectTrigger className="w-full xl:w-36">
<SelectValue>{getStatusLabel(statusFilterInput)}</SelectValue>
</SelectTrigger>
<SelectContent>
{statusOptions.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="w-full xl:w-64">
<OptionCombobox
value={tagFilterInput}
options={tagOptions}
placeholder="选择标签"
searchPlaceholder="搜索标签路径"
emptyText="没有匹配标签"
onChange={setTagFilterInput}
/>
</div>
<div className="w-full xl:w-56">
<OptionCombobox
value={assigneeFilterInput}
options={assigneeOptions}
placeholder="选择指派人"
searchPlaceholder="搜索指派人"
emptyText="没有匹配指派人"
onChange={setAssigneeFilterInput}
/>
</div>
<div className="w-full xl:w-56">
<OptionCombobox
value={agentTeamFilterInput}
options={agentTeamOptions}
placeholder="选择客服组"
searchPlaceholder="搜索客服组"
emptyText="没有匹配客服组"
onChange={setAgentTeamFilterInput}
/>
</div>
<Button variant="outline" onClick={applyFilters} disabled={loading}>
<SearchIcon />
</Button>
<Button
variant="outline"
onClick={() => void handleRefresh()}
disabled={loading || refreshing}
>
<RefreshCwIcon className={loading || refreshing ? "animate-spin" : ""} />
</Button>
</div>
<div className="space-y-4">
<div className="overflow-hidden rounded-2xl border bg-background">
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-28 text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{result.results.map((item) => {
const statusMeta = getStatusMeta(item.status)
return (
<TableRow key={item.id}>
<TableCell className="max-w-60">
<div className="min-w-0">
<div className="font-medium">{item.subject || `会话 #${item.id}`}</div>
<div className="mt-1 text-sm text-muted-foreground">
{item.externalSource || "-"}
</div>
<div className="mt-1 line-clamp-2 text-sm text-muted-foreground">
{item.lastMessageSummary || "暂无最新消息摘要"}
</div>
</div>
</TableCell>
<TableCell>
<Badge variant={statusMeta.variant}>{statusMeta.label}</Badge>
</TableCell>
<TableCell>{getServiceModeLabel(item.serviceMode)}</TableCell>
<TableCell>{item.currentAssigneeName || "-"}</TableCell>
<TableCell>
{item.agentUnreadCount} / {item.customerUnreadCount}
</TableCell>
<TableCell>{formatDateTime(item.lastMessageAt)}</TableCell>
<TableCell className="text-right">
<ButtonGroup className="ml-auto">
<Button
variant="outline"
size="sm"
onClick={() => void openDetail(item)}
disabled={actionLoadingId === item.id}
>
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" size="icon-sm" />}
aria-label={`更多操作 ${item.subject || item.id}`}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44 min-w-44">
<DropdownMenuItem
onClick={() => openAssign(item)}
disabled={actionLoadingId === item.id || item.status !== 2}
>
<MessageCircleMoreIcon />
{actionLoadingId === item.id ? "处理中..." : "分配会话"}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void handleDispatch(item)}
disabled={actionLoadingId === item.id || item.status !== 2}
>
<RefreshCwIcon />
{actionLoadingId === item.id ? "处理中..." : "重试分配"}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void handleRead(item)}
disabled={actionLoadingId === item.id}
>
<CheckCheckIcon />
{actionLoadingId === item.id ? "处理中..." : "标记已读"}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => openTransfer(item)}
disabled={actionLoadingId === item.id || item.status !== 3}
>
<MessageCircleMoreIcon />
{actionLoadingId === item.id ? "处理中..." : "转接会话"}
</DropdownMenuItem>
{item.status !== 4 ? (
<DropdownMenuItem
onClick={() => openClose(item)}
disabled={actionLoadingId === item.id}
>
<RefreshCwIcon />
{actionLoadingId === item.id ? "处理中..." : "关闭会话"}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
</TableCell>
</TableRow>
)
})}
{!loading && result.results.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="py-12 text-center text-muted-foreground">
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</div>
<ListPagination
page={result.page.page}
total={result.page.total}
limit={result.page.limit}
loading={loading || refreshing}
onPageChange={handlePageChange}
onLimitChange={handleLimitChange}
/>
</div>
</div>
<ConversationDetailDialog
open={detailOpen}
loading={detailLoading}
saving={actionLoadingId === detailItem?.id}
item={detailItem}
detail={detailData}
messages={detailMessages}
messagesHasMore={detailMessagesHasMore}
loadingMoreMessages={detailMessagesLoadingMore}
onLoadMoreMessages={loadMoreDetailMessages}
onOpenChange={handleDetailOpenChange}
onOpenAssign={() => {
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)
}}
/>
<ConversationCloseDialog
open={closeOpen}
conversationId={closeItem?.id ?? null}
onOpenChange={handleCloseOpenChange}
onSuccess={async () => {
const conversationId = closeItem?.id
setCloseOpen(false)
setCloseItem(null)
if (conversationId) {
await handleConversationChanged(conversationId)
}
}}
/>
<ConversationTransferDialog
open={assignOpen}
mode="assign"
conversationId={assignItem?.id ?? null}
onOpenChange={handleAssignOpenChange}
onSuccess={async () => {
const conversationId = assignItem?.id
setAssignOpen(false)
setAssignItem(null)
if (conversationId) {
await handleConversationChanged(conversationId)
}
}}
/>
<ConversationTransferDialog
open={transferOpen}
mode="transfer"
conversationId={transferItem?.id ?? null}
onOpenChange={handleTransferOpenChange}
onSuccess={async () => {
const conversationId = transferItem?.id
setTransferOpen(false)
setTransferItem(null)
if (conversationId) {
await handleConversationChanged(conversationId)
}
}}
/>
</>
)
}