"use client"; import { ChevronDownIcon, ChevronRightIcon, Cog, ExternalLinkIcon, Heart, MessageSquarePlusIcon, PanelRightCloseIcon, PanelRightOpenIcon, PencilIcon, PlusIcon, RefreshCcwIcon, RotateCcwIcon, UserRoundPlusIcon, XIcon, } from "lucide-react"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { useCallback, useEffect, useMemo, useState, type ReactNode, } from "react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { ButtonGroup } from "@/components/ui/button-group"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { fetchAgentProfilesAll, fetchConversationDetail, type AdminAgentProfile, type AdminConversationDetail, } from "@/lib/api/admin"; import { deleteTicketCollaborator, deleteTicketRelation, fetchTicketDetail, unwatchTicket, updateTicket, watchTicket, type CreateTicketPayload, type TicketDetail, type TicketItem, type UpdateTicketPayload, } from "@/lib/api/ticket"; import { readSession } from "@/lib/auth"; import { getEnumLabel } from "@/lib/enums"; import { IMConversationStatus, IMConversationStatusLabels, } from "@/lib/generated/enums"; import { formatDateTime } from "@/lib/utils"; import { EditDialog } from "../_components/edit"; import { TicketAssignDialog } from "../_components/ticket-assign-dialog"; import { TicketCollaboratorDialog } from "../_components/ticket-collaborator-dialog"; import { TicketCustomerPanel } from "../_components/ticket-customer-panel"; import { TicketPriorityBadge } from "../_components/ticket-priority-badge"; import { TicketReasonDialog } from "../_components/ticket-reason-dialog"; import { TicketRelationDialog } from "../_components/ticket-relation-dialog"; import { TicketReplyDialog } from "../_components/ticket-reply-dialog"; import { TicketSLABadge } from "../_components/ticket-sla-badge"; import { TicketStatusBadge, ticketStatusLabel, } from "../_components/ticket-status-badge"; import { TicketStatusDialog } from "../_components/ticket-status-dialog"; function formatTicketSource(source?: string) { switch (source) { case "conversation": return "会话转工单"; case "manual": return "手动创建"; case "email": return "邮件"; case "api": return "API"; case "system": return "系统"; default: return source || "—"; } } function formatSLAStatus(status: string) { switch (status) { case "running": return "进行中"; case "paused": return "已暂停"; case "completed": return "已完成"; case "breached": return "已超时"; default: return status; } } function ticketSeverityLabel(severity: number) { switch (severity) { case 1: return "轻微"; case 2: return "一般"; case 3: return "严重"; default: return String(severity); } } function ticketRelationLabel(relationType?: string) { switch (relationType) { case "duplicate": return "重复工单"; case "related": return "相关工单"; case "parent": return "父工单"; case "child": return "子工单"; default: return relationType || "关联工单"; } } function ticketEventLabel(eventType?: string) { switch (eventType) { case "mentioned": return "提及协作人"; case "internal_noted": return "内部备注"; case "replied": return "回复客户"; case "status_changed": return "状态变更"; case "assigned": return "指派工单"; case "transferred": return "转派工单"; default: return eventType || "事件"; } } function getMainSLA(ticket: TicketItem | null) { return ( ticket?.sla?.find((item) => item.slaType === "resolution") ?? ticket?.sla?.[0] ?? null ); } function parseMentionUserIds(payload?: string) { if (!payload) { return []; } try { const data = JSON.parse(payload) as { mentionUserIds?: number[] }; return Array.isArray(data.mentionUserIds) ? data.mentionUserIds : []; } catch { return []; } } function isDoneTicketStatus(status?: string) { return status === "resolved" || status === "closed" || status === "cancelled"; } export default function TicketDetailPage() { const searchParams = useSearchParams(); const router = useRouter(); const ticketId = Number(searchParams.get("id") || 0); const [detail, setDetail] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [replyDialogOpen, setReplyDialogOpen] = useState(false); const [assignDialogOpen, setAssignDialogOpen] = useState(false); const [statusDialogOpen, setStatusDialogOpen] = useState(false); const [closeDialogOpen, setCloseDialogOpen] = useState(false); const [reopenDialogOpen, setReopenDialogOpen] = useState(false); const [editDialogOpen, setEditDialogOpen] = useState(false); const [relationDialogOpen, setRelationDialogOpen] = useState(false); const [collaboratorDialogOpen, setCollaboratorDialogOpen] = useState(false); const [collapsedSections, setCollapsedSections] = useState< Record >({}); const [rightPanelCollapsed, setRightPanelCollapsed] = useState(false); const [sourceConversation, setSourceConversation] = useState(null); const [agents, setAgents] = useState([]); const ticket = detail?.ticket ?? null; const currentUserId = readSession()?.user?.id ?? 0; const isWatching = Boolean( detail?.watchers?.some((item) => item.userId === currentUserId), ); const resolutionSLA = useMemo(() => getMainSLA(ticket), [ticket]); const childRelations = useMemo( () => (detail?.relatedTickets || []).filter( (item) => item.relationType === "child", ), [detail?.relatedTickets], ); const childProgress = useMemo(() => { const total = childRelations.length; const completed = childRelations.filter((item) => isDoneTicketStatus(item.relatedTicketStatus), ).length; return { total, completed, active: total - completed, }; }, [childRelations]); const toggleSection = useCallback((sectionKey: string) => { setCollapsedSections((current) => ({ ...current, [sectionKey]: !current[sectionKey], })); }, []); const loadDetail = useCallback(async () => { if (!ticketId) { setDetail(null); setLoading(false); return; } setLoading(true); try { const data = await fetchTicketDetail(ticketId); setDetail(data); } catch (error) { toast.error(error instanceof Error ? error.message : "加载工单详情失败"); } finally { setLoading(false); } }, [ticketId]); useEffect(() => { void loadDetail(); }, [loadDetail]); useEffect(() => { void (async () => { try { const data = await fetchAgentProfilesAll(); setAgents(Array.isArray(data) ? data : []); } catch { setAgents([]); } })(); }, []); useEffect(() => { if (!ticket?.conversationId) { setSourceConversation(null); return; } void (async () => { try { const data = await fetchConversationDetail(ticket.conversationId); setSourceConversation(data); } catch { setSourceConversation(null); } })(); }, [ticket?.conversationId]); async function handleWatchToggle() { if (!ticket) { return; } setSaving(true); try { if (isWatching) { await unwatchTicket(ticket.id); toast.success("已取消关注"); } else { await watchTicket(ticket.id); toast.success("已关注工单"); } await loadDetail(); } catch (error) { toast.error(error instanceof Error ? error.message : "更新关注状态失败"); } finally { setSaving(false); } } async function handleEditSubmit( payload: CreateTicketPayload | UpdateTicketPayload, ) { if (!("ticketId" in payload)) { toast.error("详情页仅支持编辑现有工单"); return; } setSaving(true); try { await updateTicket(payload); toast.success("工单基础信息已更新"); setEditDialogOpen(false); await loadDetail(); } catch (error) { toast.error(error instanceof Error ? error.message : "更新工单失败"); } finally { setSaving(false); } } async function handleDeleteRelation(relationId: number) { if (!ticket) { return; } setSaving(true); try { await deleteTicketRelation({ ticketId: ticket.id, relationId }); toast.success("关联工单已移除"); await loadDetail(); } catch (error) { toast.error(error instanceof Error ? error.message : "移除关联工单失败"); } finally { setSaving(false); } } async function handleDeleteCollaborator(collaboratorId: number) { if (!ticket) { return; } setSaving(true); try { await deleteTicketCollaborator({ ticketId: ticket.id, collaboratorId }); toast.success("协作人已移除"); await loadDetail(); } catch (error) { toast.error(error instanceof Error ? error.message : "移除协作人失败"); } finally { setSaving(false); } } return (
{loading ? (
加载中...
) : ticket ? ( <>
{/* 工单header */}
{ticket.ticketNo}
{ticket.status === "closed" ? ( ) : ( )}

{ticket.title}

{ticket.description || "暂无工单描述"}
回复&备注 事件记录 {(detail?.comments?.length || 0) > 0 ? (
{detail?.comments?.map((comment) => (
{comment.authorName || `用户#${comment.authorId}`}
{comment.commentType === "public_reply" ? "客户回复" : "内部备注"}
{comment.createdAt ? formatDateTime(comment.createdAt) : "—"}
{comment.commentType === "internal_note" && parseMentionUserIds(comment.payload).length ? (
{parseMentionUserIds(comment.payload).map( (userId) => { const user = agents.find( (item) => item.userId === userId, ); return ( @ {user?.displayName || user?.nickname || user?.username || `用户#${userId}`} ); }, )}
) : null}
{comment.content}
))}
) : (
暂无评论记录
)} {(detail?.events?.length || 0) > 0 ? ( detail?.events?.map((event) => (
{event.eventType === "mentioned" ? ( 提及 ) : null}
{event.content || ticketEventLabel(event.eventType)}
{event.createdAt ? formatDateTime(event.createdAt) : "—"}
{event.operatorName || `用户#${event.operatorId}`}
{event.payload && event.eventType === "mentioned" && parseMentionUserIds(event.payload).length ? (
{parseMentionUserIds(event.payload).map( (userId) => { const user = agents.find( (item) => item.userId === userId, ); return ( @ {user?.displayName || user?.nickname || user?.username || `用户#${userId}`} ); }, )}
) : null}
)) ) : (
暂无事件记录
)}
{rightPanelCollapsed ? null : (
toggleSection("basics")} className="px-4 pt-3 lg:px-6 lg:pt-4" action={ } contentClassName="space-y-0 text-sm" > 0 ? ticket.tags.map((tag) => tag.name).join(" / ") : "未打标签" } /> toggleSection("customer")} className="px-4 pt-4 lg:px-6" contentClassName="text-sm" > toggleSection("sla")} className="px-4 pt-4 lg:px-6" contentClassName="space-y-3 text-sm" > {ticket.sla?.length ? ( ticket.sla.map((sla) => (
{sla.slaType === "first_response" ? "首次响应" : "解决时效"}
目标:{sla.targetMinutes} 分钟
状态:{formatSLAStatus(sla.status)}
已耗时:{sla.elapsedMin} 分钟
{sla.breachedAt ? (
超时于:{formatDateTime(sla.breachedAt)}
) : null}
)) ) : (
当前工单未生成 SLA 记录,请检查工单 SLA 配置;如果系统仍在使用默认策略,也需要确认配置是否已补齐。 前往配置优先级
)}
toggleSection("resolution")} className="px-4 pt-4 lg:px-6" contentClassName="space-y-0 text-sm" > toggleSection("source")} className="px-4 pt-4 lg:px-6" contentClassName="space-y-3 text-sm" > {sourceConversation ? (
{sourceConversation.subject || "未命名会话"}
状态: {getEnumLabel( IMConversationStatusLabels, sourceConversation.status as IMConversationStatus, )} 处理人: {sourceConversation.currentAssigneeName || "未指派"}
{sourceConversation.lastMessageSummary || "暂无最近消息摘要"}
最近活跃: {sourceConversation.lastActiveAt ? formatDateTime(sourceConversation.lastActiveAt) : "—"}
) : null} {ticket.conversationId ? ( ) : null}
toggleSection("related")} className="px-4 pt-4 lg:px-6" action={ } contentClassName="space-y-3 text-sm" > {childProgress.total > 0 ? (
子工单进展
总数:{childProgress.total} 已完成:{childProgress.completed} 进行中:{childProgress.active}
) : null} {detail?.relatedTickets?.length ? ( detail.relatedTickets.map((relation) => (
{ticketRelationLabel(relation.relationType)}
{relation.relatedTicketNo || `#${relation.relatedTicketId}`}
{relation.relatedTicketTitle || "未命名工单"}
{relation.relationType === "child" ? ( {isDoneTicketStatus( relation.relatedTicketStatus, ) ? "子单已完成" : "子单处理中"} ) : null} 状态: {ticketStatusLabel( relation.relatedTicketStatus || "", )} 团队:{relation.currentTeamName || "未分组"} 处理人: {relation.currentAssigneeName || "未指派"}
最近更新: {relation.updatedAt ? formatDateTime(relation.updatedAt) : "—"}
)) ) : (
暂无关联工单
)}
toggleSection("collaborators")} className="px-4 pt-4 lg:px-6" action={ } contentClassName="space-y-3 text-sm" > {detail?.collaborators?.length ? ( detail.collaborators.map((collaborator) => (
{collaborator.userName || `用户#${collaborator.userId}`}
{collaborator.teamName || "未分组"}
)) ) : (
暂无协作人
)}
toggleSection("watchers")} className="px-4 pt-4 lg:px-6" contentClassName="space-y-3 text-sm" > {detail?.watchers?.length ? ( detail.watchers.map((watcher) => ( )) ) : (
暂无关注人
)}
)}
) : (
工单不存在
)}
); } function InfoRow({ label, value }: { label: string; value: string }) { return (
{label} {value}
); } function DetailSection({ title, description, action, collapsible, collapsed, onToggle, className, contentClassName, children, }: { title?: string; description?: string; action?: ReactNode; collapsible?: boolean; collapsed?: boolean; onToggle?: () => void; className?: string; contentClassName?: string; children: ReactNode; }) { return (
{title || description || action ? (
{collapsible ? ( ) : null} {title ?

{title}

: null}
{description ? (

{description}

) : null}
{action ?
{action}
: null}
) : null} {collapsed ? null :
{children}
}
); } function SurfacePanel({ className, children, }: { className?: string; children: ReactNode; }) { return (
{children}
); }