调整目录

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,677 @@
"use client";
import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { ConversationTransferDialog } from "@/components/conversation-actions/transfer-dialog";
import { ImMessageEditor } from "@/components/im-message-editor";
import { ImMessageHTML } from "@/components/im-message-html";
import { useImageLightbox } from "@/components/image-lightbox";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { useIsLgUp } from "@/hooks/use-lg-media";
import {
assignAgentConversation,
type AgentMessage,
} from "@/lib/api/agent";
import { readSession } from "@/lib/auth";
import { renderIMMessageHTML } from "@/lib/im-message";
import {
agentConversationSelectors,
useAgentConversationsStore,
type AgentConversationFilterKey,
} from "@/lib/stores/agent-conversations";
import { formatDateTime } from "@/lib/utils";
const EMPTY_AGENT_MESSAGES: AgentMessage[] = [];
export function ChatPanel() {
const conversation = useAgentConversationsStore(
agentConversationSelectors.selectedConversation,
);
const messages =
useAgentConversationsStore((state) => state.messages) ??
EMPTY_AGENT_MESSAGES;
const loading = useAgentConversationsStore((state) => state.messagesLoading);
const sending = useAgentConversationsStore((state) => state.sending);
const uploadingAsset = useAgentConversationsStore(
(state) => state.uploadingAsset,
);
const sendMessage = useAgentConversationsStore((state) => state.sendMessage);
const uploadImage = useAgentConversationsStore((state) => state.uploadImage);
const sendAttachment = useAgentConversationsStore((state) => state.sendAttachment);
const markSelectedConversationRead = useAgentConversationsStore(
(state) => state.markSelectedConversationRead,
);
const recallMessage = useAgentConversationsStore((state) => state.recallMessage);
const recallingMessageId = useAgentConversationsStore(
(state) => state.recallingMessageId,
);
const loadConversations = useAgentConversationsStore((state) => state.loadConversations);
const loadMessages = useAgentConversationsStore((state) => state.loadMessages);
const loadOlderMessages = useAgentConversationsStore(
(state) => state.loadOlderMessages,
);
const messagesHasMore = useAgentConversationsStore(
(state) => state.messagesHasMore,
);
const messagesLoadingMore = useAgentConversationsStore(
(state) => state.messagesLoadingMore,
);
const conversationFilter = useAgentConversationsStore((state) => state.conversationFilter);
const setConversationFilter = useAgentConversationsStore(
(state) => state.setConversationFilter,
);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const messagesContentRef = useRef<HTMLDivElement>(null);
const scrollBottomRafRef = useRef<number | null>(null);
const shouldStickToBottomRef = useRef(true);
const prependScrollAnchorRef = useRef<{ height: number; top: number } | null>(
null,
);
const [claiming, setClaiming] = useState(false);
const [claimDialogOpen, setClaimDialogOpen] = useState(false);
const [transferDialogOpen, setTransferDialogOpen] = useState(false);
const isLgUp = useIsLgUp();
const isClosedConversation = conversation?.status === 4;
const isPendingConversation = conversation?.status === 2;
const showMessageEditor = !isClosedConversation && !isPendingConversation;
const currentUserId = readSession()?.user?.id ?? 0;
const switchToMyActiveIfNeeded = () => {
if (conversationFilter !== "pending") {
return;
}
setConversationFilter("active" satisfies AgentConversationFilterKey);
};
const getViewport = useCallback(
() => messagesContainerRef.current,
[],
);
const isNearBottom = useCallback(
(element: HTMLElement, threshold = 80) =>
element.scrollHeight - element.scrollTop - element.clientHeight <=
threshold,
[],
);
const scrollToBottom = useCallback(() => {
const viewport = getViewport();
if (!viewport) {
return;
}
viewport.scrollTop = viewport.scrollHeight;
}, [getViewport]);
/**
* 与 widget 消息列表一致:在单条调度链内多帧滚底直到 scrollHeight 稳定,
* 避免多段滚底叠加导致滚动条抖动。
*/
const scheduleScrollToBottom = useCallback(
(attempts = 4) => {
if (scrollBottomRafRef.current !== null) {
cancelAnimationFrame(scrollBottomRafRef.current);
}
const run = (remaining: number, previousHeight = -1) => {
scrollBottomRafRef.current = requestAnimationFrame(() => {
const viewport = getViewport();
if (!viewport) {
scrollBottomRafRef.current = null;
return;
}
const currentHeight = viewport.scrollHeight;
scrollToBottom();
if (remaining > 1 && currentHeight !== previousHeight) {
run(remaining - 1, currentHeight);
return;
}
scrollBottomRafRef.current = null;
});
};
run(attempts);
},
[getViewport, scrollToBottom],
);
const handleImageSettled = useCallback(() => {
if (!shouldStickToBottomRef.current) {
return;
}
scheduleScrollToBottom();
}, [scheduleScrollToBottom]);
const maybeMarkConversationRead = useCallback(() => {
const viewport = getViewport();
if (!viewport || !conversation || loading) {
return;
}
if (
typeof document !== "undefined" &&
document.visibilityState !== "visible"
) {
return;
}
if (!isNearBottom(viewport)) {
return;
}
void markSelectedConversationRead().catch((error) => {
toast.error(error instanceof Error ? error.message : "设置已读失败");
});
}, [
conversation,
getViewport,
isNearBottom,
loading,
markSelectedConversationRead,
]);
useEffect(() => {
const viewport = getViewport();
if (!viewport) {
return;
}
const handleScroll = () => {
shouldStickToBottomRef.current = isNearBottom(viewport);
if (shouldStickToBottomRef.current) {
maybeMarkConversationRead();
}
};
handleScroll();
viewport.addEventListener("scroll", handleScroll);
return () => {
viewport.removeEventListener("scroll", handleScroll);
};
}, [conversation?.id, getViewport, isNearBottom, maybeMarkConversationRead]);
useLayoutEffect(() => {
shouldStickToBottomRef.current = true;
scheduleScrollToBottom();
return () => {
if (scrollBottomRafRef.current !== null) {
cancelAnimationFrame(scrollBottomRafRef.current);
scrollBottomRafRef.current = null;
}
};
}, [conversation?.id, scheduleScrollToBottom]);
useLayoutEffect(() => {
const viewport = getViewport();
if (!viewport) {
return;
}
const anchor = prependScrollAnchorRef.current;
if (anchor) {
prependScrollAnchorRef.current = null;
const nextHeight = viewport.scrollHeight;
viewport.scrollTop = nextHeight - anchor.height + anchor.top;
return;
}
if (shouldStickToBottomRef.current) {
scheduleScrollToBottom();
}
}, [messages, getViewport, scheduleScrollToBottom]);
useEffect(() => {
const content = messagesContentRef.current;
if (!content) {
return;
}
const observer = new ResizeObserver(() => {
if (!shouldStickToBottomRef.current) {
return;
}
scheduleScrollToBottom();
});
observer.observe(content);
return () => {
observer.disconnect();
};
}, [conversation?.id, scheduleScrollToBottom]);
useEffect(() => {
maybeMarkConversationRead();
}, [maybeMarkConversationRead, messages.length]);
useEffect(() => {
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") {
maybeMarkConversationRead();
}
};
const handleFocus = () => {
maybeMarkConversationRead();
};
document.addEventListener("visibilitychange", handleVisibilityChange);
window.addEventListener("focus", handleFocus);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
window.removeEventListener("focus", handleFocus);
};
}, [maybeMarkConversationRead]);
const handleLoadOlder = async () => {
const viewport = getViewport();
if (!viewport || messagesLoadingMore || !messagesHasMore) {
return;
}
prependScrollAnchorRef.current = {
height: viewport.scrollHeight,
top: viewport.scrollTop,
};
try {
await loadOlderMessages();
} catch (error) {
prependScrollAnchorRef.current = null;
toast.error(error instanceof Error ? error.message : "加载历史消息失败");
}
};
const handleSend = async (html: string) => {
if (!conversation || sending || isClosedConversation) return;
try {
shouldStickToBottomRef.current = true;
await sendMessage(html);
} catch (error) {
toast.error(error instanceof Error ? error.message : "发送消息失败");
}
};
const handleClaim = async () => {
if (!conversation || claiming) return;
const session = readSession();
if (!session?.user?.id) {
toast.error("未登录或登录已过期");
return;
}
setClaiming(true);
try {
await assignAgentConversation(
conversation.id,
session.user.id,
"认领会话",
);
switchToMyActiveIfNeeded();
setClaimDialogOpen(false);
toast.success("认领成功");
await reloadConversationData(conversation.id);
} catch (error) {
toast.error(error instanceof Error ? error.message : "认领会话失败");
} finally {
setClaiming(false);
}
};
const reloadConversationData = async (conversationId: number) => {
await loadConversations();
await loadMessages(conversationId, { forceLoading: true, reset: true });
};
if (!conversation) {
return (
<div className="mt-10 flex flex-1 items-center justify-center px-4">
<div className="text-center text-muted-foreground">
<p className="text-lg"></p>
<p className="mt-1 text-sm lg:hidden"></p>
<p className="mt-1 hidden text-sm lg:block"></p>
</div>
</div>
);
}
const messagesScroll = (
<div
ref={messagesContainerRef}
className="h-full min-h-0 flex-1 overflow-y-auto p-4 cs-agent-scrollbar"
>
<div ref={messagesContentRef} className="flex flex-col">
{!loading && messages.length > 0 && messagesHasMore ? (
<div className="mb-4 flex justify-center">
<Button
type="button"
variant="outline"
size="sm"
disabled={messagesLoadingMore}
onClick={() => void handleLoadOlder()}
>
{messagesLoadingMore ? "加载中…" : "加载更早的消息"}
</Button>
</div>
) : null}
{loading ? (
<div className="py-8 text-center text-sm text-muted-foreground">
...
</div>
) : messages.length > 0 ? (
messages.map((message) => (
<MessageItem
key={message.id}
message={message}
onImageSettled={handleImageSettled}
canRecall={message.senderType === "agent" && message.senderId === currentUserId}
recalling={recallingMessageId === message.id}
onRecall={async (messageId) => {
await recallMessage(messageId);
}}
/>
))
) : (
<div className="py-8 text-center text-sm text-muted-foreground">
</div>
)}
</div>
</div>
);
const bottomPanel = (
<div className="h-full overflow-auto border-t border-border bg-background">
{isClosedConversation ? (
<div className="h-full flex justify-center items-center">
</div>
) : conversation?.status === 1 ? (
<div className="h-full flex justify-center items-center">
AI
</div>
) : isPendingConversation ? (
<div className="h-full flex justify-center items-center">
<div className="flex items-center gap-2 h-full">
<Button
onClick={() => setClaimDialogOpen(true)}
disabled={claiming}
size="sm"
>
{claiming ? "认领中..." : "认领"}
</Button>
</div>
</div>
) : (
<div className="flex h-full min-h-0 flex-col">
<div className="min-h-0 flex-1">
<ImMessageEditor
disabled={!conversation || sending}
uploadingAsset={uploadingAsset}
onSend={handleSend}
onUploadImage={async (file) => {
shouldStickToBottomRef.current = true;
const uploaded = await uploadImage(file);
return uploaded;
}}
onSendAttachment={async (file) => {
shouldStickToBottomRef.current = true;
try {
await sendAttachment(file);
} catch (error) {
toast.error(error instanceof Error ? error.message : "发送附件失败");
}
}}
/>
</div>
</div>
)}
</div>
);
return (
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden">
{isLgUp ? (
<ResizablePanelGroup
orientation="vertical"
className="flex min-h-0 flex-1 flex-col"
>
<ResizablePanel
defaultSize={showMessageEditor ? "72%" : "82%"}
minSize="35%"
className="min-h-0"
>
{messagesScroll}
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
defaultSize={showMessageEditor ? "28%" : "18%"}
minSize={showMessageEditor ? "18%" : "12%"}
maxSize={showMessageEditor ? "55%" : "30%"}
className="min-h-0"
>
{bottomPanel}
</ResizablePanel>
</ResizablePanelGroup>
) : (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="min-h-0 flex-1">{messagesScroll}</div>
<div className="shrink-0 pb-[env(safe-area-inset-bottom)] lg:pb-0">
{bottomPanel}
</div>
</div>
)}
<Dialog
open={claimDialogOpen}
onOpenChange={(open) => {
if (claiming) {
return;
}
setClaimDialogOpen(open);
}}
>
<DialogContent className="max-w-md" showCloseButton={false}>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{conversation
? `确认认领“${conversation.subject}”吗?认领后会话会进入我的列表。`
: "确认认领当前会话吗?"}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={claiming}
onClick={() => setClaimDialogOpen(false)}
>
</Button>
<Button
type="button"
disabled={claiming}
onClick={() => void handleClaim()}
>
{claiming ? "认领中..." : "确认认领"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ConversationTransferDialog
open={transferDialogOpen}
mode="transfer"
conversationId={conversation.id}
onOpenChange={setTransferDialogOpen}
onSuccess={async () => {
await reloadConversationData(conversation.id);
}}
/>
</div>
);
}
type MessageItemProps = {
message: AgentMessage;
onImageSettled: () => void;
canRecall: boolean;
recalling: boolean;
onRecall: (messageId: number) => Promise<void>;
};
const MessageItem = memo(
function MessageItem({
message,
onImageSettled,
canRecall,
recalling,
onRecall,
}: MessageItemProps) {
const { open: openImageLightbox } = useImageLightbox();
const isCustomer = message.senderType === "customer";
const isAi = message.senderType === "ai";
const isAgentSide = message.senderType === "agent" || isAi;
const isRecalled = Boolean(message.recalledAt) || message.sendStatus === 6;
const senderName = isCustomer
? message.senderName || "客户"
: isAi
? "AI"
: message.senderName || "客服";
const agentAvatarSrc =
isAgentSide && !isAi && message.senderAvatar?.trim()
? message.senderAvatar.trim()
: undefined;
const avatarFallback = isAi ? "AI" : senderName.charAt(0);
const htmlContent = isRecalled ? "<p>该消息已撤回</p>" : buildMessageHTML(message);
const bubbleClassName = isAi
? "border border-primary/15 bg-primary/5 text-foreground shadow-sm"
: isAgentSide
? "bg-emerald-600 text-white shadow-sm"
: "border border-border/70 bg-muted/60 text-foreground shadow-sm";
const htmlClassName = isAi
? "[&_a]:text-foreground [&_a]:underline [&_img]:rounded-md"
: isAgentSide
? "[&_p]:text-white [&_a]:text-white [&_a]:underline [&_img]:rounded-md"
: "[&_a]:text-foreground [&_a]:underline [&_img]:rounded-md";
const avatarClassName = isAi
? "border border-primary/20 bg-primary/10 text-xs text-foreground"
: isAgentSide
? "bg-emerald-600 text-xs text-white"
: "border border-border/70 bg-muted/60 text-xs text-foreground";
const recalledBubbleClassName = isAgentSide
? "border border-dashed border-emerald-200 bg-emerald-50 text-emerald-800"
: "border border-dashed border-border/70 bg-muted/40 text-muted-foreground";
const recalledHtmlClassName = isAgentSide
? "[&_p]:text-emerald-800"
: "[&_p]:text-muted-foreground";
const showRecallAction = canRecall && !isRecalled;
return (
<div
className={`mb-4 flex items-start gap-2 ${
isAgentSide ? "justify-end" : "justify-start"
}`}
>
{isAgentSide ? (
<>
<div className="flex max-w-[70%] flex-col items-end">
<div className="mb-1 text-xs text-muted-foreground">
{senderName}
</div>
<div
className={`w-fit rounded-2xl px-3 py-2 text-left ${
isRecalled ? recalledBubbleClassName : bubbleClassName
}`}
>
<ImMessageHTML
html={htmlContent}
className={isRecalled ? recalledHtmlClassName : htmlClassName}
onImageSettled={onImageSettled}
onImageClick={isRecalled ? undefined : openImageLightbox}
/>
</div>
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
<span>{formatDateTime(message.sentAt || "")}</span>
{isRecalled ? <span></span> : null}
{message.sendStatus === 2 && !isRecalled && (
<span>{message.customerRead ? "客户已读" : "客户未读"}</span>
)}
{showRecallAction ? (
<Button
type="button"
variant="ghost"
size="sm"
className="h-auto px-1 py-0 text-xs text-muted-foreground"
disabled={recalling}
onClick={() => {
void onRecall(message.id).catch((error) => {
toast.error(error instanceof Error ? error.message : "撤回消息失败");
});
}}
>
{recalling ? "撤回中..." : "撤回"}
</Button>
) : null}
</div>
</div>
<Avatar className="size-8 shrink-0">
<AvatarImage src={agentAvatarSrc ?? ""} />
<AvatarFallback className={avatarClassName}>
{avatarFallback}
</AvatarFallback>
</Avatar>
</>
) : (
<>
<Avatar className="size-8 shrink-0">
<AvatarImage src="" />
<AvatarFallback className={avatarClassName}>
</AvatarFallback>
</Avatar>
<div className="max-w-[70%]">
<div className="mb-1 text-xs text-muted-foreground">
{senderName}
</div>
<div
className={`w-fit rounded-2xl px-3 py-2 ${
isRecalled ? recalledBubbleClassName : bubbleClassName
}`}
>
<ImMessageHTML
html={htmlContent}
className={isRecalled ? recalledHtmlClassName : htmlClassName}
onImageSettled={onImageSettled}
onImageClick={isRecalled ? undefined : openImageLightbox}
/>
</div>
<div className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
<span>{formatDateTime(message.sentAt || "")}</span>
{isRecalled ? <span></span> : null}
</div>
</div>
</>
)}
</div>
);
},
(prevProps, nextProps) =>
prevProps.message === nextProps.message &&
prevProps.onImageSettled === nextProps.onImageSettled &&
prevProps.canRecall === nextProps.canRecall &&
prevProps.recalling === nextProps.recalling &&
prevProps.onRecall === nextProps.onRecall,
);
function buildMessageHTML(message: {
messageType: string;
content: string;
payload?: string;
}) {
return renderIMMessageHTML(message);
}
@@ -0,0 +1,757 @@
"use client";
import Link from "next/link";
import {
Building2Icon,
Link2Icon,
MailIcon,
PencilIcon,
PhoneIcon,
UserRoundIcon,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { type CustomerFormSavePayload } from "@/components/customer-form";
import { CustomerFormDialog } from "@/components/customer-form-dialog";
import { CustomerLinkOrCreateDialog } from "@/components/customer-link-or-create-dialog";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Field,
FieldContent,
FieldLabel,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import type { AgentConversation } from "@/lib/api/agent";
import { type TagTree, fetchTagsAll } from "@/lib/api/admin";
import { updateCompany, type AdminCompany } from "@/lib/api/company";
import { fetchTickets, type TicketItem } from "@/lib/api/ticket";
import {
fetchCustomer,
saveCustomerProfile,
type AdminCustomer,
} from "@/lib/api/customer";
import {
fetchCustomerContacts,
type AdminCustomerContact,
} from "@/lib/api/customer-contact";
import {
ContactType,
ContactTypeLabels,
Gender,
GenderLabels,
} from "@/lib/generated/enums";
import { useAgentConversationsStore } from "@/lib/stores/agent-conversations";
import { cn, formatDateTime } from "@/lib/utils";
import {
ConversationTagBadges,
ConversationTagPicker,
} from "./conversation-tag-picker";
import { TicketPriorityBadge } from "../../tickets/_components/ticket-priority-badge";
import { TicketStatusBadge } from "../../tickets/_components/ticket-status-badge";
function contactTypeLabel(contactType: ContactType | string) {
return ContactTypeLabels[contactType as ContactType] ?? contactType;
}
function ContactTypeIcon({ contactType }: { contactType: ContactType | string }) {
const cls = "size-3.5 shrink-0 text-muted-foreground";
switch (contactType) {
case ContactType.Mobile:
return <PhoneIcon className={cls} aria-hidden />;
case ContactType.Email:
return <MailIcon className={cls} aria-hidden />;
default:
return <Link2Icon className={cls} aria-hidden />;
}
}
function DetailRow({
label,
value,
valueClassName,
}: {
label: string;
value: string;
valueClassName?: string;
}) {
const empty = !value.trim();
return (
<div className="flex gap-2.5 text-sm leading-snug">
<span className="w-17 shrink-0 pt-px text-xs text-muted-foreground">{label}</span>
<span
className={cn(
"min-w-0 flex-1 break-all text-foreground",
empty && "text-muted-foreground",
valueClassName,
)}
>
{empty ? "—" : value}
</span>
</div>
);
}
function SectionHeading({
children,
action,
}: {
children: React.ReactNode;
action?: React.ReactNode;
}) {
return (
<div className="flex items-center justify-between gap-2">
<h3 className="text-xs font-medium text-muted-foreground">{children}</h3>
{action}
</div>
);
}
function UnlinkedCustomerEmpty({ conversation }: { conversation: AgentConversation }) {
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const loadConversations = useAgentConversationsStore((s) => s.loadConversations);
return (
<div className="space-y-6 pt-2">
<div className="flex flex-col items-center justify-center rounded-xl bg-muted/35 px-4 py-8 text-center">
<UserRoundIcon className="mb-2 size-10 text-muted-foreground" aria-hidden />
<p className="text-sm font-medium text-foreground"> CRM </p>
<p className="mt-1 max-w-xs text-xs leading-relaxed text-muted-foreground">
</p>
<Button
type="button"
className="mt-4 gap-2"
onClick={() => setLinkDialogOpen(true)}
>
<Link2Icon className="size-4" />
</Button>
</div>
<CustomerLinkOrCreateDialog
open={linkDialogOpen}
onOpenChange={setLinkDialogOpen}
conversationId={conversation.id}
onSuccess={() => void loadConversations()}
/>
</div>
);
}
function MissingCustomerEmpty({ conversation }: { conversation: AgentConversation }) {
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const loadConversations = useAgentConversationsStore((s) => s.loadConversations);
return (
<div className="space-y-6 pt-2">
<div className="flex flex-col items-center justify-center rounded-xl bg-muted/35 px-4 py-8 text-center">
<UserRoundIcon className="mb-2 size-10 text-muted-foreground" aria-hidden />
<p className="text-sm font-medium text-foreground"></p>
<p className="mt-1 max-w-xs text-xs leading-relaxed text-muted-foreground">
</p>
<Button
type="button"
className="mt-4 gap-2"
onClick={() => setLinkDialogOpen(true)}
>
<Link2Icon className="size-4" />
</Button>
</div>
<div className="space-y-2">
<SectionHeading>访</SectionHeading>
<div className="space-y-2">
<DetailRow label="外部来源" value={conversation.externalSource} />
<DetailRow label="外部标识" value={conversation.externalId} />
</div>
</div>
<CustomerLinkOrCreateDialog
open={linkDialogOpen}
onOpenChange={setLinkDialogOpen}
conversationId={conversation.id}
onSuccess={() => void loadConversations()}
/>
</div>
);
}
type ConversationInfoPanelProps = {
conversation: AgentConversation | null;
className?: string;
variant?: "default" | "embedded";
};
export function ConversationInfoPanel({
conversation,
className,
variant = "default",
}: ConversationInfoPanelProps) {
const embedded = variant === "embedded";
return (
<div
className={cn(
"flex h-full min-h-0 flex-col overflow-hidden",
embedded
? "bg-background text-foreground"
: "border-border bg-card text-card-foreground",
className,
)}
>
<div className="flex h-12.5 shrink-0 items-center border-b border-border px-3">
<h2 className="text-sm font-medium text-foreground"></h2>
</div>
<div
className={cn(
"min-h-0 flex-1 overflow-y-auto px-3 pb-4",
embedded && "pb-[max(1rem,env(safe-area-inset-bottom))] pt-1",
)}
>
{!conversation ? (
<p className="pt-4 text-sm text-muted-foreground">
{embedded
? "请选择会话以查看会话信息"
: "请选择左侧会话以查看会话信息"}
</p>
) : (
<div className="space-y-4 py-3">
<CustomerBody conversation={conversation} />
</div>
)}
</div>
</div>
);
}
function ConversationTagSection({
conversation,
}: {
conversation: AgentConversation;
}) {
const setConversationTags = useAgentConversationsStore(
(state) => state.setConversationTags,
);
const [availableTags, setAvailableTags] = useState<TagTree[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
let cancelled = false;
async function loadTags() {
setLoading(true);
try {
const data = await fetchTagsAll();
if (!cancelled) {
setAvailableTags(Array.isArray(data) ? data : []);
}
} catch (error) {
if (!cancelled) {
toast.error(error instanceof Error ? error.message : "加载标签失败");
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
void loadTags();
return () => {
cancelled = true;
};
}, []);
return (
<section className="space-y-2 border-t pt-2">
<SectionHeading
action={
<ConversationTagPicker
conversation={conversation}
availableTags={availableTags}
loading={loading}
onTagsChange={(tags) => {
setConversationTags(conversation.id, tags);
}}
/>
}
>
</SectionHeading>
<ConversationTagBadges
tags={conversation.tags}
availableTags={availableTags}
/>
{!conversation.tags || conversation.tags.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : null}
</section>
);
}
function CustomerBody({ conversation }: { conversation: AgentConversation }) {
const customerId = conversation.customerId ?? 0;
if (customerId <= 0) {
return (
<div className="space-y-4">
<UnlinkedCustomerEmpty conversation={conversation} />
<ConversationTagSection conversation={conversation} />
</div>
);
}
return <CustomerLinkedBody conversation={conversation} customerId={customerId} />;
}
type CustomerLinkedBodyProps = {
conversation: AgentConversation;
customerId: number;
};
function CustomerLinkedBody({ conversation, customerId }: CustomerLinkedBodyProps) {
const [loading, setLoading] = useState(true);
const [customer, setCustomer] = useState<AdminCustomer | null>(null);
const [contacts, setContacts] = useState<AdminCustomerContact[]>([]);
const [customerEditOpen, setCustomerEditOpen] = useState(false);
const [customerEditSaving, setCustomerEditSaving] = useState(false);
const [companyEditOpen, setCompanyEditOpen] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const c = await fetchCustomer(customerId);
setCustomer(c);
if (!c) {
setContacts([]);
return;
}
const list = await fetchCustomerContacts(customerId);
setContacts(Array.isArray(list) ? list : []);
} catch (e) {
const msg = e instanceof Error ? e.message : "加载客户信息失败";
toast.error(msg);
setCustomer(null);
setContacts([]);
} finally {
setLoading(false);
}
}, [customerId]);
useEffect(() => {
void load();
}, [load]);
const isProfileEmpty =
customer &&
!customer.name.trim() &&
!customer.primaryMobile.trim() &&
!customer.primaryEmail.trim() &&
customer.companyId === 0 &&
!customer.remark.trim();
if (loading && !customer) {
return (
<p className="pt-4 text-sm text-muted-foreground"></p>
);
}
if (!customer) {
return (
<div className="space-y-4">
<MissingCustomerEmpty conversation={conversation} />
<ConversationTagSection conversation={conversation} />
</div>
);
}
const displayName = customer.name.trim() || "未填写姓名";
const company = customer.company ?? null;
const genderLabel =
customer.gender === Gender.Male || customer.gender === Gender.Female
? GenderLabels[customer.gender as Gender] ?? String(customer.gender)
: null;
return (
<div className="space-y-4">
{isProfileEmpty ? (
<div className="rounded-lg bg-amber-500/10 px-3 py-2.5 text-xs leading-relaxed text-amber-950 dark:text-amber-100">
</div>
) : null}
<section className="space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-1 items-start gap-2 text-sm">
<UserRoundIcon
className="mt-0.5 size-4 shrink-0 text-muted-foreground"
aria-hidden
/>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="line-clamp-2 leading-snug text-foreground">
<span className="font-medium">{displayName}</span>
{genderLabel ? (
<span className="font-normal text-muted-foreground">
{" "}
· {genderLabel}
</span>
) : null}
</p>
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 shrink-0 gap-1 px-2 text-xs"
onClick={() => setCustomerEditOpen(true)}
>
<PencilIcon className="size-3.5" />
</Button>
</div>
<div className="space-y-2">
<DetailRow
label="最近活跃"
value={
customer.lastActiveAt ? formatDateTime(customer.lastActiveAt) : ""
}
/>
<DetailRow
label="备注"
value={customer.remark.trim() ? customer.remark : ""}
valueClassName="whitespace-pre-wrap"
/>
<DetailRow
label="创建时间"
value={formatDateTime(customer.createdAt)}
valueClassName="whitespace-pre-wrap"
/>
<DetailRow
label="更新时间"
value={formatDateTime(customer.updatedAt)}
valueClassName="whitespace-pre-wrap"
/>
</div>
</section>
<section className="space-y-2">
{contacts.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : (
<ul className="space-y-3">
{contacts.map((row) => {
const tags: string[] = [];
if (row.isPrimary) {
tags.push("主");
}
if (row.isVerified) {
tags.push("已验证");
}
return (
<li key={row.id} className="text-sm">
<div className="flex items-center gap-2">
<ContactTypeIcon contactType={row.contactType} />
<div className="min-w-0 flex-1">
<p className="break-all font-medium leading-snug text-foreground">
{row.contactValue}
<span className="ml-2 text-xs font-normal text-muted-foreground">
{contactTypeLabel(row.contactType)}
</span>
{tags.length > 0 ? (
<span className="ml-2 text-xs text-muted-foreground">
{tags.join(" · ")}
</span>
) : null}
</p>
{row.remark ? (
<p className="mt-1 line-clamp-3 break-all text-xs leading-relaxed text-muted-foreground">
{row.remark}
</p>
) : null}
</div>
</div>
</li>
);
})}
</ul>
)}
</section>
{customer.companyId > 0 ? (
<section className="border-t pt-2">
{company ? (
<div className="space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 flex-1 items-start gap-2 text-sm">
<Building2Icon
className="mt-0.5 size-4 shrink-0 text-muted-foreground"
aria-hidden
/>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="line-clamp-2 font-medium leading-snug text-foreground">
{company.name}
</p>
{company.code ? (
<p className="font-mono text-xs text-muted-foreground">
{company.code}
</p>
) : null}
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 shrink-0 gap-1 px-2 text-xs"
onClick={() => setCompanyEditOpen(true)}
>
<PencilIcon className="size-3.5" />
</Button>
</div>
<div className="space-y-2 pt-1">
<DetailRow
label="创建"
value={formatDateTime(company.createdAt)}
/>
<DetailRow
label="更新"
value={formatDateTime(company.updatedAt)}
/>
</div>
<DetailRow
label="备注"
value={company.remark.trim() ? company.remark : ""}
valueClassName="whitespace-pre-wrap"
/>
</div>
) : (
<p className="text-sm text-muted-foreground">
</p>
)}
</section>
) : null}
<RelatedTicketsSection conversation={conversation} />
<ConversationTagSection conversation={conversation} />
<CustomerFormDialog
open={customerEditOpen}
onOpenChange={setCustomerEditOpen}
saving={customerEditSaving}
itemId={customer.id}
onSave={async (payload: CustomerFormSavePayload) => {
if (customerEditSaving) {
return;
}
setCustomerEditSaving(true);
try {
await saveCustomerProfile({ ...payload, id: customer.id });
toast.success("已保存");
void load();
setCustomerEditOpen(false);
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
} finally {
setCustomerEditSaving(false);
}
}}
/>
{company ? (
<CompanyEditDialog
open={companyEditOpen}
onOpenChange={setCompanyEditOpen}
company={company}
onSaved={() => {
void load();
}}
/>
) : null}
</div>
);
}
function RelatedTicketsSection({ conversation }: { conversation: AgentConversation }) {
const [tickets, setTickets] = useState<TicketItem[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
let cancelled = false;
async function loadTickets() {
setLoading(true);
try {
const data = await fetchTickets({
conversationId: conversation.id,
page: 1,
limit: 5,
});
if (!cancelled) {
setTickets(Array.isArray(data.results) ? data.results : []);
}
} catch (error) {
if (!cancelled) {
toast.error(error instanceof Error ? error.message : "加载关联工单失败");
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
void loadTickets();
return () => {
cancelled = true;
};
}, [conversation.id]);
return (
<section className="space-y-2 border-t pt-2">
<SectionHeading></SectionHeading>
{loading ? (
<p className="text-sm text-muted-foreground"></p>
) : tickets.length > 0 ? (
<div className="space-y-2">
{tickets.map((ticket) => (
<Link
key={ticket.id}
href={`/tickets/detail?id=${ticket.id}`}
target="_blank"
rel="noreferrer"
className="block rounded-lg border border-border bg-background px-3 py-2 transition-colors hover:bg-muted/40"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">
{ticket.title}
</div>
<div className="mt-0.5 text-xs text-muted-foreground">
{ticket.ticketNo}
</div>
</div>
<TicketPriorityBadge priority={ticket.priority} priorityName={ticket.priorityName} />
</div>
<div className="mt-2 flex items-center justify-between gap-3">
<TicketStatusBadge status={ticket.status} />
<span className="text-xs text-muted-foreground">
{ticket.updatedAt ? formatDateTime(ticket.updatedAt) : "—"}
</span>
</div>
</Link>
))}
</div>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</section>
);
}
type CompanyEditDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
company: AdminCompany;
onSaved: () => void;
};
function CompanyEditDialog({
open,
onOpenChange,
company,
onSaved,
}: CompanyEditDialogProps) {
const [name, setName] = useState("");
const [code, setCode] = useState("");
const [remark, setRemark] = useState("");
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) {
return;
}
setName(company.name);
setCode(company.code);
setRemark(company.remark);
}, [open, company]);
const handleSubmit = async () => {
const trimmedName = name.trim();
if (!trimmedName) {
toast.error("公司名称不能为空");
return;
}
setSaving(true);
try {
await updateCompany({
id: company.id,
name: trimmedName,
code: code.trim(),
remark: remark.trim(),
});
toast.success("已保存");
onSaved();
onOpenChange(false);
} catch (e) {
toast.error(e instanceof Error ? e.message : "保存失败");
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md" showCloseButton>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 py-1">
<Field orientation="vertical">
<FieldLabel htmlFor="co-name"></FieldLabel>
<FieldContent>
<Input id="co-name" value={name} onChange={(e) => setName(e.target.value)} />
</FieldContent>
</Field>
<Field orientation="vertical">
<FieldLabel htmlFor="co-code"></FieldLabel>
<FieldContent>
<Input id="co-code" value={code} onChange={(e) => setCode(e.target.value)} />
</FieldContent>
</Field>
<Field orientation="vertical">
<FieldLabel htmlFor="co-remark"></FieldLabel>
<FieldContent>
<Textarea
id="co-remark"
value={remark}
onChange={(e) => setRemark(e.target.value)}
rows={3}
/>
</FieldContent>
</Field>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button type="button" disabled={saving} onClick={() => void handleSubmit()}>
{saving ? "保存中…" : "保存"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,123 @@
"use client"
import { UserIcon } from "lucide-react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { ScrollArea } from "@/components/ui/scroll-area"
import { formatDateTime } from "@/lib/utils"
import { useAgentConversationsStore } from "@/lib/stores/agent-conversations"
import {
IMConversationStatus,
IMConversationStatusLabels,
} from "@/lib/generated/enums"
import { getEnumLabel } from "@/lib/enums"
function getStatusVariant(status: number) {
switch (status) {
case IMConversationStatus.AIServing:
return "bg-violet-500/15 text-violet-700 dark:bg-violet-500/20 dark:text-violet-300"
case IMConversationStatus.Pending:
return "bg-blue-500/15 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300"
case IMConversationStatus.Active:
return "bg-emerald-500/15 text-emerald-800 dark:bg-emerald-500/20 dark:text-emerald-300"
case IMConversationStatus.Closed:
return "bg-muted text-muted-foreground"
default:
return "bg-muted text-muted-foreground"
}
}
type ConversationListProps = {
onAfterSelect?: () => void
}
export function ConversationList({ onAfterSelect }: ConversationListProps) {
const conversations = useAgentConversationsStore((state) => state.conversations)
const loading = useAgentConversationsStore((state) => state.conversationsLoading)
const selectedId = useAgentConversationsStore((state) => state.selectedConversationId)
const selectConversation = useAgentConversationsStore((state) => state.selectConversation)
return (
<ScrollArea className="flex-1 bg-transparent">
<div className="divide-y divide-border">
{loading ? (
<div className="p-6 text-center text-sm text-muted-foreground">
...
</div>
) : conversations.length > 0 ? (
conversations.map((conversation) => {
const isSelected = selectedId === conversation.id
return (
<div
key={conversation.id}
className={`cursor-pointer px-2.5 py-1.5 transition-colors hover:bg-muted/50 ${
isSelected ? "bg-muted/80" : ""
}`}
onClick={() => {
void selectConversation(conversation.id).then(
() => {
onAfterSelect?.()
},
() => {},
)
}}
>
<div className="overflow-hidden">
<div className="flex items-center gap-2">
<Avatar className="size-7 shrink-0">
<AvatarImage src="" />
<AvatarFallback className="bg-primary/10">
<UserIcon className="size-3.5 text-primary" />
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="min-w-0 flex-1 truncate font-medium text-sm leading-4">
{conversation.subject}
</span>
{conversation.agentUnreadCount > 0 ? (
<div className="flex size-4.5 shrink-0 items-center justify-center rounded-full bg-primary text-[10px] text-primary-foreground">
{conversation.agentUnreadCount > 99
? "99+"
: conversation.agentUnreadCount}
</div>
) : null}
</div>
<div className="mt-0.5 text-[11px] text-muted-foreground">
{conversation.lastMessageAt
? formatDateTime(conversation.lastMessageAt)
: "暂无时间"}
</div>
</div>
</div>
<div className="mt-0.5 truncate text-xs leading-4 text-muted-foreground">
{conversation.lastMessageSummary || "暂无最新消息"}
</div>
<div className="mt-1 flex items-center gap-1 text-[10px] text-muted-foreground">
<span
className={`rounded px-1 py-0.5 ${getStatusVariant(
conversation.status
)}`}
>
{getEnumLabel(IMConversationStatusLabels, conversation.status)}
</span>
{conversation.externalSource ? (
<>
<span className="opacity-40">·</span>
<span className="truncate">{conversation.externalSource}</span>
</>
) : null}
</div>
</div>
</div>
)
})
) : (
<div className="p-6 text-center text-sm text-muted-foreground">
</div>
)}
</div>
</ScrollArea>
)
}
@@ -0,0 +1,215 @@
"use client"
import { CheckIcon, Loader2Icon, TagIcon } from "lucide-react"
import { useMemo, useState } from "react"
import { toast } from "sonner"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import {
addConversationTag,
removeConversationTag,
type AgentConversation,
type AgentConversationTag,
} from "@/lib/api/agent"
import { type TagTree } from "@/lib/api/admin"
import { cn } from "@/lib/utils"
type TagNode = TagTree & {
depth: number
}
function flattenTagTree(nodes: TagTree[], depth = 0): TagNode[] {
const result: TagNode[] = []
nodes.forEach((item) => {
result.push({ ...item, depth })
if (item.children.length > 0) {
result.push(...flattenTagTree(item.children, depth + 1))
}
})
return result
}
function buildTagPathMap(
nodes: TagTree[],
parentPath = ""
): Map<number, string> {
const result = new Map<number, string>()
nodes.forEach((item) => {
const currentPath = parentPath ? `${parentPath} / ${item.name}` : item.name
result.set(item.id, currentPath)
if (item.children.length > 0) {
buildTagPathMap(item.children, currentPath).forEach((value, key) => {
result.set(key, value)
})
}
})
return result
}
type ConversationTagPickerProps = {
conversation: AgentConversation
availableTags: TagTree[]
loading?: boolean
onTagsChange: (tags: AgentConversationTag[]) => void
}
export function ConversationTagPicker({
conversation,
availableTags,
loading = false,
onTagsChange,
}: ConversationTagPickerProps) {
const [pendingTagId, setPendingTagId] = useState<number | null>(null)
const flattenedTags = useMemo(() => flattenTagTree(availableTags), [availableTags])
const selectedTagIds = useMemo(
() => new Set((conversation.tags ?? []).map((item) => item.id)),
[conversation.tags]
)
async function handleToggle(tag: TagNode) {
if (pendingTagId !== null) {
return
}
const exists = selectedTagIds.has(tag.id)
const currentTags = conversation.tags ?? []
const nextTags = exists
? currentTags.filter((item) => item.id !== tag.id)
: [...currentTags, { id: tag.id, name: tag.name }]
setPendingTagId(tag.id)
try {
if (exists) {
await removeConversationTag({
conversationId: conversation.id,
tagId: tag.id,
})
} else {
await addConversationTag({
conversationId: conversation.id,
tagId: tag.id,
})
}
onTagsChange(nextTags)
toast.success(exists ? "已移除会话标签" : "已添加会话标签")
} catch (error) {
toast.error(error instanceof Error ? error.message : "更新会话标签失败")
} finally {
setPendingTagId(null)
}
}
return (
<Popover>
<PopoverTrigger
render={
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 shrink-0 gap-1 px-2 text-xs"
aria-label="编辑会话标签"
/>
}
>
<TagIcon className="size-3.5 text-muted-foreground" />
</PopoverTrigger>
<PopoverContent
align="end"
className="w-72 p-0"
onClick={(event) => event.stopPropagation()}
>
<Command>
<CommandInput placeholder="搜索标签" />
<CommandList>
{loading ? <CommandEmpty>...</CommandEmpty> : null}
{!loading && flattenedTags.length === 0 ? (
<CommandEmpty></CommandEmpty>
) : null}
{!loading ? (
<CommandGroup heading="标签">
{flattenedTags.map((tag) => {
const checked = selectedTagIds.has(tag.id)
const pending = pendingTagId === tag.id
return (
<CommandItem
key={tag.id}
value={`${tag.id} ${tag.name} ${tag.remark}`}
disabled={pendingTagId !== null}
onSelect={() => void handleToggle(tag)}
>
{pending ? (
<Loader2Icon className="mr-2 size-4 animate-spin" />
) : (
<CheckIcon
className={cn(
"mr-2 size-4",
checked ? "opacity-100" : "opacity-0"
)}
/>
)}
<span
className="truncate"
style={{ paddingLeft: `${tag.depth * 12}px` }}
>
{tag.name}
</span>
</CommandItem>
)
})}
</CommandGroup>
) : null}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
type ConversationTagBadgesProps = {
tags?: AgentConversationTag[]
availableTags?: TagTree[]
}
export function ConversationTagBadges({
tags,
availableTags = [],
}: ConversationTagBadgesProps) {
if (!tags || tags.length === 0) {
return null
}
const tagPathMap = buildTagPathMap(availableTags)
return (
<div className="flex flex-wrap items-center gap-1.5">
{tags.map((tag) => (
<Badge
key={tag.id}
variant="outline"
className="max-w-full px-2 text-[12px] font-normal"
>
<span className="break-all">
{tagPathMap.get(tag.id) ?? tag.name}
</span>
</Badge>
))}
</div>
)
}
+506
View File
@@ -0,0 +1,506 @@
"use client";
import {
ArrowRightLeftIcon,
ChevronLeft,
ChevronRight,
ChevronsUpDown,
CircleUserRoundIcon,
CircleXIcon,
FilePlus2Icon,
Menu,
MoreHorizontalIcon,
X,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import type { PanelImperativeHandle } from "react-resizable-panels";
import { ConversationCloseDialog } from "@/components/conversation-actions/close-dialog";
import { ConversationTransferDialog } from "@/components/conversation-actions/transfer-dialog";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Sheet, SheetContent } from "@/components/ui/sheet";
import { useAgentConversationRealtime } from "@/hooks/use-agent-conversation-realtime";
import {
agentConversationFilterOptions,
agentConversationSelectors,
type AgentConversationFilterKey,
useAgentConversationsStore,
} from "@/lib/stores/agent-conversations";
import { CreateTicketFromConversationDialog } from "../tickets/_components/create-ticket-from-conversation-dialog";
import { ChatPanel } from "./_components/chat-panel";
import { ConversationInfoPanel } from "./_components/conversation-info-panel";
import { ConversationList } from "./_components/conversation-list";
export default function ConversationsPage() {
const conversation = useAgentConversationsStore(
agentConversationSelectors.selectedConversation,
);
const conversationFilter = useAgentConversationsStore(
(state) => state.conversationFilter,
);
const setConversationFilter = useAgentConversationsStore(
(state) => state.setConversationFilter,
);
const loadConversations = useAgentConversationsStore(
(state) => state.loadConversations,
);
const loadMessages = useAgentConversationsStore(
(state) => state.loadMessages,
);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [infoPanelCollapsed, setInfoPanelCollapsed] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [mobileCustomerSheetOpen, setMobileCustomerSheetOpen] = useState(false);
const [transferOpen, setTransferOpen] = useState(false);
const [closeOpen, setCloseOpen] = useState(false);
const [createTicketOpen, setCreateTicketOpen] = useState(false);
const sidebarPanelRef = useRef<PanelImperativeHandle | null>(null);
const infoPanelRef = useRef<PanelImperativeHandle | null>(null);
const filterContainerRef = useRef<HTMLDivElement | null>(null);
const filterMeasureRef = useRef<HTMLDivElement | null>(null);
const [showFilterDropdown, setShowFilterDropdown] = useState(false);
useEffect(() => {
const container = filterContainerRef.current;
const measure = filterMeasureRef.current;
if (!container || !measure) {
return;
}
const updateFilterMode = () => {
setShowFilterDropdown(measure.scrollWidth > container.clientWidth);
};
updateFilterMode();
const observer = new ResizeObserver(() => {
updateFilterMode();
});
observer.observe(container);
observer.observe(measure);
return () => {
observer.disconnect();
};
}, []);
const currentFilterOption =
agentConversationFilterOptions.find((opt) => opt.value === conversationFilter) ??
agentConversationFilterOptions[0];
useEffect(() => {
void loadConversations().catch((error) => {
toast.error(error instanceof Error ? error.message : "加载会话列表失败");
});
}, [loadConversations, conversationFilter]);
async function handleConversationChanged(conversationId: number) {
await loadConversations();
await loadMessages(conversationId, {
forceLoading: false,
reset: false,
});
}
useAgentConversationRealtime();
const handleSidebarToggle = () => {
const panel = sidebarPanelRef.current;
if (!panel) {
setSidebarCollapsed((current) => !current);
return;
}
if (panel.isCollapsed()) {
panel.expand();
setSidebarCollapsed(false);
return;
}
panel.collapse();
setSidebarCollapsed(true);
};
const handleInfoPanelToggle = () => {
const panel = infoPanelRef.current;
if (!panel) {
setInfoPanelCollapsed((current) => !current);
return;
}
if (panel.isCollapsed()) {
panel.expand();
setInfoPanelCollapsed(false);
return;
}
panel.collapse();
setInfoPanelCollapsed(true);
};
const renderConversationSidebar = (opts?: { onListAfterSelect?: () => void }) => (
<div className="flex h-full min-h-0 flex-1 flex-col bg-inherit">
<div className="flex h-12.5 shrink-0 items-start justify-between gap-2 border-b border-border p-2">
<div ref={filterContainerRef} className="relative min-w-0 flex-1">
{showFilterDropdown ? (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="outline"
className="h-8.5 w-full min-w-0 justify-between gap-2 px-3 text-xs sm:text-sm"
/>
}
>
<span className="truncate">{currentFilterOption?.label ?? "筛选状态"}</span>
<ChevronsUpDown className="size-4 shrink-0 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-44 min-w-44">
<DropdownMenuRadioGroup
value={conversationFilter}
onValueChange={(value) =>
setConversationFilter(value as AgentConversationFilterKey)
}
>
{agentConversationFilterOptions.map((opt) => (
<DropdownMenuRadioItem key={opt.value} value={opt.value}>
{opt.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Tabs
value={conversationFilter}
onValueChange={(value) =>
setConversationFilter(value as AgentConversationFilterKey)
}
className="min-w-0 flex-1 gap-0"
>
<TabsList
className="w-full min-w-0 justify-start"
>
{agentConversationFilterOptions.map((opt) => (
<TabsTrigger
key={opt.value}
value={opt.value}
className="shrink-0 px-2.5 text-xs sm:text-sm"
>
{opt.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
)}
<div
ref={filterMeasureRef}
className="pointer-events-none absolute whitespace-nowrap opacity-0"
aria-hidden="true"
>
<div className="inline-flex">
{agentConversationFilterOptions.map((opt) => (
<span
key={opt.value}
className="shrink-0 px-2.5 text-xs sm:text-sm"
>
{opt.label}
</span>
))}
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="mt-0.5 shrink-0 lg:hidden"
onClick={() => setMobileMenuOpen(false)}
>
<X className="size-4" />
</Button>
</div>
<ConversationList onAfterSelect={opts?.onListAfterSelect} />
</div>
);
const workspaceContent = (
<div className="flex h-full min-h-0 w-full flex-1 flex-col overflow-hidden bg-background text-foreground">
<div className="flex h-12.5 shrink-0 items-center justify-between gap-3 border-b border-border px-3 py-1">
<div className="flex min-w-0 items-center gap-2 sm:gap-3">
<Button
variant="ghost"
size="icon"
className="lg:hidden"
onClick={() => setMobileMenuOpen(true)}
>
<Menu className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="hidden lg:flex"
onClick={handleSidebarToggle}
>
{sidebarCollapsed ? (
<ChevronRight className="size-4" />
) : (
<ChevronLeft className="size-4" />
)}
</Button>
{conversation ? (
<>
<Avatar className="size-8 shrink-0 lg:size-9">
<AvatarImage src="" />
<AvatarFallback></AvatarFallback>
</Avatar>
<div className="min-w-0">
<div className="flex items-center gap-2">
<p className="min-w-0 truncate font-medium leading-tight">
{conversation.subject}
</p>
<span
className={`inline-flex shrink-0 items-center gap-1 rounded-full border px-2 text-[11px] ${
conversation.customerOnline
? "border-emerald-200 bg-emerald-50 text-emerald-700"
: "border-slate-200 bg-slate-100 text-slate-600"
}`}
>
<span
className={`size-1.5 rounded-full ${
conversation.customerOnline
? "bg-emerald-500"
: "bg-slate-400"
}`}
/>
{conversation.customerOnline ? "用户在线" : "用户离线"}
</span>
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground sm:text-sm">
<span>{conversation.externalSource}</span>
<span className="text-muted-foreground/60"> / </span>
<span>{conversation.externalId}</span>
{conversation.customerId ? (
<>
<span className="text-muted-foreground/60"> / </span>
<span></span>
</>
) : null}
</p>
</div>
</>
) : (
<div className="min-w-0">
<p className="truncate font-medium text-[14px] leading-tight"></p>
<p className="mt-0.5 truncate text-[14px] text-muted-foreground sm:text-[14px] lg:hidden">
</p>
<p className="mt-0.5 hidden truncate text-[12px] text-muted-foreground lg:block">
</p>
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-0.5 sm:gap-1">
<Button
variant="ghost"
size="icon"
className="lg:hidden"
disabled={!conversation}
aria-label="会话信息"
onClick={() => setMobileCustomerSheetOpen(true)}
>
<CircleUserRoundIcon className="size-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon" disabled={!conversation} />
}
>
<MoreHorizontalIcon className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44 min-w-44">
<DropdownMenuItem
onClick={() => setCreateTicketOpen(true)}
disabled={!conversation}
>
<FilePlus2Icon />
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setTransferOpen(true)}
disabled={!conversation || conversation.status !== 3}
>
<ArrowRightLeftIcon />
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setCloseOpen(true)}
disabled={!conversation || conversation.status === 4}
>
<CircleXIcon />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="ghost"
size="icon"
className="hidden lg:flex"
onClick={handleInfoPanelToggle}
aria-label={infoPanelCollapsed ? "展开会话信息" : "收起会话信息"}
>
{infoPanelCollapsed ? (
<ChevronLeft className="size-4" />
) : (
<ChevronRight className="size-4" />
)}
</Button>
</div>
</div>
<div className="flex min-h-0 w-full flex-1 overflow-hidden">
<ChatPanel />
</div>
</div>
);
return (
<div className="flex h-[calc(100dvh-var(--header-height))] min-h-0 w-full min-w-0 flex-col overflow-hidden lg:h-full">
{/* H5 无左侧导航:顶栏 h-12、left-0lg 起有 w-14 侧栏,与 layout 一致 */}
{mobileMenuOpen && (
<button
type="button"
aria-label="关闭会话列表"
className="fixed top-12 right-0 bottom-0 left-0 z-30 bg-black/50 lg:hidden"
onClick={() => setMobileMenuOpen(false)}
/>
)}
<div
className={`fixed top-12 bottom-0 left-0 z-40 flex w-[min(22rem,calc(100vw-0.75rem))] max-w-[min(22rem,calc(100vw-0.75rem))] flex-col overflow-hidden border-r border-border bg-card text-card-foreground shadow-lg transition-transform duration-300 ease-out will-change-transform touch-manipulation overscroll-contain supports-[padding:max(0px)]:pb-[env(safe-area-inset-bottom)] lg:hidden ${
mobileMenuOpen ? "translate-x-0" : "-translate-x-full pointer-events-none"
}`}
aria-hidden={!mobileMenuOpen}
>
{renderConversationSidebar({
onListAfterSelect: () => setMobileMenuOpen(false),
})}
</div>
<div className="flex min-h-0 min-w-0 w-full flex-1 flex-col overflow-hidden lg:hidden">
{workspaceContent}
</div>
<div className="hidden min-h-0 w-full flex-1 overflow-hidden lg:flex">
<ResizablePanelGroup orientation="horizontal">
<ResizablePanel
panelRef={sidebarPanelRef}
defaultSize="20%"
minSize="10%"
maxSize="40%"
collapsedSize="0%"
collapsible
onResize={(panelSize: { asPercentage: number }) => {
setSidebarCollapsed(panelSize.asPercentage <= 1);
}}
className="min-h-0"
>
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-card text-card-foreground">
{renderConversationSidebar()}
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize="50%" minSize="32%" className="min-h-0">
<div className="flex h-full min-h-0 flex-col overflow-hidden">
{workspaceContent}
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel
panelRef={infoPanelRef}
defaultSize="500px"
minSize="20%"
maxSize="40%"
collapsedSize="0%"
collapsible
onResize={(panelSize: { asPercentage: number }) => {
setInfoPanelCollapsed(panelSize.asPercentage <= 1);
}}
className="min-h-0"
>
<ConversationInfoPanel conversation={conversation} className="h-full" />
</ResizablePanel>
</ResizablePanelGroup>
</div>
<ConversationTransferDialog
open={transferOpen}
mode="transfer"
conversationId={conversation?.id ?? null}
onOpenChange={setTransferOpen}
onSuccess={async () => {
setTransferOpen(false);
if (conversation?.id) {
await handleConversationChanged(conversation.id);
}
}}
/>
<ConversationCloseDialog
open={closeOpen}
conversationId={conversation?.id ?? null}
onOpenChange={setCloseOpen}
onSuccess={async () => {
setCloseOpen(false);
if (conversation?.id) {
await handleConversationChanged(conversation.id);
}
}}
/>
<CreateTicketFromConversationDialog
open={createTicketOpen}
onOpenChange={setCreateTicketOpen}
conversation={
conversation
? {
id: conversation.id,
subject: conversation.subject,
customerId: conversation.customerId ?? 0,
lastMessageSummary: conversation.lastMessageSummary,
currentAssigneeId: conversation.currentAssigneeId,
}
: null
}
onSuccess={() => {
setCreateTicketOpen(false);
}}
/>
<Sheet open={mobileCustomerSheetOpen} onOpenChange={setMobileCustomerSheetOpen}>
<SheetContent
side="right"
className="flex w-full flex-col gap-0 border-l p-0 sm:max-w-md"
showCloseButton
>
<ConversationInfoPanel
conversation={conversation}
variant="embedded"
className="min-h-0 flex-1"
/>
</SheetContent>
</Sheet>
</div>
);
}