"use client";
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 { 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 ;
case ContactType.Email:
return ;
default:
return ;
}
}
function DetailRow({
label,
value,
valueClassName,
}: {
label: string;
value: string;
valueClassName?: string;
}) {
const empty = !value.trim();
return (
{label}
{empty ? "—" : value}
);
}
function SectionHeading({
children,
action,
}: {
children: React.ReactNode;
action?: React.ReactNode;
}) {
return (
{children}
{action}
);
}
function UnlinkedCustomerEmpty({ conversation }: { conversation: AgentConversation }) {
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const loadConversations = useAgentConversationsStore((s) => s.loadConversations);
return (
尚未关联 CRM 客户
当前会话未绑定客户主档。绑定后可在此维护公司与联系方式。
void loadConversations()}
/>
);
}
function MissingCustomerEmpty({ conversation }: { conversation: AgentConversation }) {
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const loadConversations = useAgentConversationsStore((s) => s.loadConversations);
return (
客户已删除或不存在
当前会话绑定的客户主档已不可用。你可以重新关联已有客户,或直接新建一个客户并绑定到当前会话。
void loadConversations()}
/>
);
}
type ConversationInfoPanelProps = {
conversation: AgentConversation | null;
className?: string;
variant?: "default" | "embedded";
};
export function ConversationInfoPanel({
conversation,
className,
variant = "default",
}: ConversationInfoPanelProps) {
const embedded = variant === "embedded";
return (
会话信息
{!conversation ? (
{embedded
? "请选择会话以查看会话信息"
: "请选择左侧会话以查看会话信息"}
) : (
)}
);
}
function ConversationTagSection({
conversation,
}: {
conversation: AgentConversation;
}) {
const setConversationTags = useAgentConversationsStore(
(state) => state.setConversationTags,
);
const [availableTags, setAvailableTags] = useState([]);
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 (
{
setConversationTags(conversation.id, tags);
}}
/>
}
>
会话标签
{!conversation.tags || conversation.tags.length === 0 ? (
暂未设置会话标签
) : null}
);
}
function CustomerBody({ conversation }: { conversation: AgentConversation }) {
const customerId = conversation.customerId ?? 0;
if (customerId <= 0) {
return (
);
}
return ;
}
type CustomerLinkedBodyProps = {
conversation: AgentConversation;
customerId: number;
};
function CustomerLinkedBody({ conversation, customerId }: CustomerLinkedBodyProps) {
const [loading, setLoading] = useState(true);
const [customer, setCustomer] = useState(null);
const [contacts, setContacts] = useState([]);
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 (
加载客户信息…
);
}
if (!customer) {
return (
);
}
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 (
{isProfileEmpty ? (
客户主档已关联,但基础信息尚未填写。请点击「编辑」补全资料。
) : null}
{displayName}
{genderLabel ? (
{" "}
· {genderLabel}
) : null}
{contacts.length === 0 ? (
暂无联系方式
) : (
{contacts.map((row) => {
const tags: string[] = [];
if (row.isPrimary) {
tags.push("主");
}
if (row.isVerified) {
tags.push("已验证");
}
return (
-
{row.contactValue}
{contactTypeLabel(row.contactType)}
{tags.length > 0 ? (
{tags.join(" · ")}
) : null}
{row.remark ? (
{row.remark}
) : null}
);
})}
)}
{customer.companyId > 0 ? (
{company ? (
{company.name}
{company.code ? (
{company.code}
) : null}
) : (
公司信息加载失败或公司已删除。
)}
) : null}
{
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 ? (
{
void load();
}}
/>
) : null}
);
}
function RelatedTicketsSection({ conversation }: { conversation: AgentConversation }) {
const [tickets, setTickets] = useState([]);
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 (
关联工单
{loading ? (
加载工单中…
) : tickets.length > 0 ? (
{tickets.map((ticket) => (
{ticket.title}
{ticket.ticketNo}
{ticket.updatedAt ? formatDateTime(ticket.updatedAt) : "—"}
))}
) : (
当前会话暂无关联工单
)}
);
}
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 (
);
}