feat: add user management features including user creation, editing, and password reset dialogs

- Implemented CreateUserDrawer for adding new users with validation.
- Added EditDrawer for editing existing user details.
- Created InitialPasswordDialog to display generated passwords after user creation.
- Developed ResetPasswordDialogs for resetting user passwords with copy functionality.
- Enhanced user listing page with filtering, pagination, and role assignment capabilities.
- Updated company picker import path to reflect new directory structure.
This commit is contained in:
mlogclub
2026-04-15 17:19:36 +08:00
parent 4d8856b9a5
commit 3d98cf1e27
80 changed files with 1 additions and 1 deletions
@@ -0,0 +1,679 @@
"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
? { url: uploaded.url, filename: uploaded.filename }
: null;
}}
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>
)
}