1272 lines
50 KiB
TypeScript
1272 lines
50 KiB
TypeScript
"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<TicketDetail | null>(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<string, boolean>
|
|
>({});
|
|
const [rightPanelCollapsed, setRightPanelCollapsed] = useState(false);
|
|
const [sourceConversation, setSourceConversation] =
|
|
useState<AdminConversationDetail | null>(null);
|
|
const [agents, setAgents] = useState<AdminAgentProfile[]>([]);
|
|
|
|
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 (
|
|
<div className="flex h-[calc(100vh-4rem)] min-w-0 overflow-hidden">
|
|
{loading ? (
|
|
<div className="min-w-0 flex-1 p-4 lg:p-6">
|
|
<div className="py-20 text-center text-muted-foreground">
|
|
加载中...
|
|
</div>
|
|
</div>
|
|
) : ticket ? (
|
|
<>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex h-full flex-col">
|
|
{/* 工单header */}
|
|
<div className="border-b border-border/70 bg-muted/10 flex flex-col gap-2.5 px-8 py-2">
|
|
<div className="flex flex-col gap-2.5 xl:flex-row xl:items-start xl:justify-between">
|
|
<div className="min-w-0 space-y-2">
|
|
<div className="text-xs font-medium tracking-wide text-muted-foreground">
|
|
{ticket.ticketNo}
|
|
</div>
|
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
|
<TicketStatusBadge status={ticket.status} />
|
|
<TicketPriorityBadge priority={ticket.priority} priorityName={ticket.priorityName} />
|
|
<TicketSLABadge ticket={ticket} />
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap justify-end gap-2">
|
|
<ButtonGroup>
|
|
<Button
|
|
size="sm"
|
|
variant={isWatching ? "default" : "outline"}
|
|
onClick={() => void handleWatchToggle()}
|
|
disabled={saving || !ticket}
|
|
>
|
|
<Heart className="size-4" />
|
|
{isWatching ? "已关注" : "关注"}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => setReplyDialogOpen(true)}
|
|
>
|
|
<MessageSquarePlusIcon className="size-4" />
|
|
回复/备注
|
|
</Button>
|
|
{ticket.status === "closed" ? (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => setReopenDialogOpen(true)}
|
|
disabled={saving}
|
|
>
|
|
<RotateCcwIcon className="size-4" />
|
|
重开工单
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => setCloseDialogOpen(true)}
|
|
disabled={saving || !ticket}
|
|
>
|
|
<XIcon className="size-4" />
|
|
关闭工单
|
|
</Button>
|
|
)}
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => setAssignDialogOpen(true)}
|
|
>
|
|
<UserRoundPlusIcon className="size-4" />
|
|
分配处理人
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => setStatusDialogOpen(true)}
|
|
>
|
|
<Cog className="size-4" />
|
|
变更状态
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => setEditDialogOpen(true)}
|
|
>
|
|
<PencilIcon className="size-4" />
|
|
{/* 编辑 */}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => void loadDetail()}
|
|
disabled={loading || saving}
|
|
>
|
|
<RefreshCcwIcon className="size-4" />
|
|
{/* 刷新 */}
|
|
</Button>
|
|
</ButtonGroup>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="min-h-0 overflow-y-auto">
|
|
<div className="space-y-0">
|
|
<DetailSection className="px-4 lg:px-6">
|
|
<div className="space-y-2.5">
|
|
<h1 className="text-xl font-semibold tracking-tight text-foreground">
|
|
{ticket.title}
|
|
</h1>
|
|
<div className="border-l-2 pl-3 border-border/70 ">
|
|
<div className="whitespace-pre-wrap break-words text-sm leading-6 text-foreground/80">
|
|
{ticket.description || "暂无工单描述"}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</DetailSection>
|
|
|
|
<DetailSection className="px-4 pt-4 lg:px-6">
|
|
<Tabs defaultValue="comments" className="gap-3">
|
|
<TabsList>
|
|
<TabsTrigger value="comments">回复&备注</TabsTrigger>
|
|
<TabsTrigger value="events">事件记录</TabsTrigger>
|
|
</TabsList>
|
|
<TabsContent value="comments" className="space-y-3">
|
|
{(detail?.comments?.length || 0) > 0 ? (
|
|
<div className="relative pl-6">
|
|
<div className="absolute top-0 bottom-0 left-[0.55rem] w-px bg-border/70" />
|
|
<div className="space-y-4">
|
|
{detail?.comments?.map((comment) => (
|
|
<div
|
|
key={`comment-${comment.id}`}
|
|
className="relative"
|
|
>
|
|
<div
|
|
className={`absolute top-2 -left-6 size-3 rounded-full ring-4 ring-background ${
|
|
comment.commentType === "public_reply"
|
|
? "bg-emerald-500"
|
|
: "bg-amber-500"
|
|
}`}
|
|
/>
|
|
<div className="rounded-lg border border-border/60 bg-muted/15 px-3.5 py-3">
|
|
<div className="mb-2 flex items-start justify-between gap-3">
|
|
<div className="min-w-0 space-y-1">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<div className="text-sm font-medium">
|
|
{comment.authorName ||
|
|
`用户#${comment.authorId}`}
|
|
</div>
|
|
<Badge
|
|
variant="outline"
|
|
className={
|
|
comment.commentType ===
|
|
"public_reply"
|
|
? "border-emerald-200 bg-emerald-50 text-emerald-700"
|
|
: "border-amber-200 bg-amber-50 text-amber-700"
|
|
}
|
|
>
|
|
{comment.commentType ===
|
|
"public_reply"
|
|
? "客户回复"
|
|
: "内部备注"}
|
|
</Badge>
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{comment.createdAt
|
|
? formatDateTime(comment.createdAt)
|
|
: "—"}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{comment.commentType === "internal_note" &&
|
|
parseMentionUserIds(comment.payload).length ? (
|
|
<div className="mb-2 flex flex-wrap gap-2">
|
|
{parseMentionUserIds(comment.payload).map(
|
|
(userId) => {
|
|
const user = agents.find(
|
|
(item) => item.userId === userId,
|
|
);
|
|
return (
|
|
<span
|
|
key={`${comment.id}-${userId}`}
|
|
className="rounded-full border px-2 py-1 text-xs text-muted-foreground"
|
|
>
|
|
@
|
|
{user?.displayName ||
|
|
user?.nickname ||
|
|
user?.username ||
|
|
`用户#${userId}`}
|
|
</span>
|
|
);
|
|
},
|
|
)}
|
|
</div>
|
|
) : null}
|
|
<div className="whitespace-pre-wrap break-words text-sm leading-6 text-foreground/90">
|
|
{comment.content}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="text-sm text-muted-foreground">
|
|
暂无评论记录
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
<TabsContent value="events" className="space-y-2">
|
|
{(detail?.events?.length || 0) > 0 ? (
|
|
detail?.events?.map((event) => (
|
|
<div
|
|
key={`event-${event.id}`}
|
|
className={`rounded-lg border px-3 py-2.5 ${
|
|
event.eventType === "mentioned"
|
|
? "border-amber-200 bg-amber-50/60"
|
|
: "border-border/60 bg-muted/20"
|
|
}`}
|
|
>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="flex items-center gap-2">
|
|
{event.eventType === "mentioned" ? (
|
|
<Badge
|
|
variant="outline"
|
|
className="border-amber-300 bg-amber-100 text-amber-800"
|
|
>
|
|
提及
|
|
</Badge>
|
|
) : null}
|
|
<div className="text-sm font-medium">
|
|
{event.content ||
|
|
ticketEventLabel(event.eventType)}
|
|
</div>
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{event.createdAt
|
|
? formatDateTime(event.createdAt)
|
|
: "—"}
|
|
</div>
|
|
</div>
|
|
<div className="mt-1 text-xs text-muted-foreground">
|
|
{event.operatorName ||
|
|
`用户#${event.operatorId}`}
|
|
</div>
|
|
{event.payload &&
|
|
event.eventType === "mentioned" &&
|
|
parseMentionUserIds(event.payload).length ? (
|
|
<div className="mt-2 flex flex-wrap gap-2">
|
|
{parseMentionUserIds(event.payload).map(
|
|
(userId) => {
|
|
const user = agents.find(
|
|
(item) => item.userId === userId,
|
|
);
|
|
return (
|
|
<span
|
|
key={`${event.id}-${userId}`}
|
|
className="rounded-full border border-amber-200 px-2 py-1 text-xs text-amber-800"
|
|
>
|
|
@
|
|
{user?.displayName ||
|
|
user?.nickname ||
|
|
user?.username ||
|
|
`用户#${userId}`}
|
|
</span>
|
|
);
|
|
},
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="text-sm text-muted-foreground">
|
|
暂无事件记录
|
|
</div>
|
|
)}
|
|
</TabsContent>
|
|
</Tabs>
|
|
</DetailSection>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="relative hidden shrink-0 border-l border-border/70 bg-background lg:block">
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
className="absolute top-2.5 left-1/2 z-10 size-6.5 -translate-x-1/2 rounded-full shadow-sm"
|
|
onClick={() => setRightPanelCollapsed((value) => !value)}
|
|
aria-label={
|
|
rightPanelCollapsed ? "展开右侧信息面板" : "折叠右侧信息面板"
|
|
}
|
|
>
|
|
{rightPanelCollapsed ? (
|
|
<PanelRightOpenIcon className="size-3.5" />
|
|
) : (
|
|
<PanelRightCloseIcon className="size-3.5" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
|
|
<div
|
|
className={`min-w-0 shrink-0 overflow-hidden bg-background transition-[width] duration-200 ${
|
|
rightPanelCollapsed ? "w-0" : "w-full lg:w-[360px]"
|
|
}`}
|
|
>
|
|
{rightPanelCollapsed ? null : (
|
|
<div className="h-full">
|
|
<div className="flex h-full flex-col gap-4">
|
|
<div className="min-h-0 overflow-y-auto">
|
|
<div className="space-y-0">
|
|
<DetailSection
|
|
title="基础信息"
|
|
collapsible
|
|
collapsed={Boolean(collapsedSections.basics)}
|
|
onToggle={() => toggleSection("basics")}
|
|
className="px-4 pt-3 lg:px-6 lg:pt-4"
|
|
action={
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setEditDialogOpen(true)}
|
|
>
|
|
<PencilIcon className="size-3.5" />
|
|
编辑
|
|
</Button>
|
|
}
|
|
contentClassName="space-y-0 text-sm"
|
|
>
|
|
<InfoRow label="工单号" value={ticket.ticketNo} />
|
|
<InfoRow
|
|
label="状态"
|
|
value={ticketStatusLabel(ticket.status)}
|
|
/>
|
|
<InfoRow
|
|
label="标签"
|
|
value={
|
|
ticket.tags && ticket.tags.length > 0
|
|
? ticket.tags.map((tag) => tag.name).join(" / ")
|
|
: "未打标签"
|
|
}
|
|
/>
|
|
<InfoRow
|
|
label="优先级"
|
|
value={ticket.priorityName || String(ticket.priority)}
|
|
/>
|
|
<InfoRow
|
|
label="严重度"
|
|
value={ticketSeverityLabel(ticket.severity)}
|
|
/>
|
|
<InfoRow
|
|
label="处理人"
|
|
value={ticket.currentAssigneeName || "未指派"}
|
|
/>
|
|
<InfoRow
|
|
label="处理团队"
|
|
value={ticket.currentTeamName || "未分组"}
|
|
/>
|
|
<InfoRow
|
|
label="解决时限"
|
|
value={
|
|
resolutionSLA
|
|
? `${resolutionSLA.targetMinutes} 分钟 / ${formatSLAStatus(resolutionSLA.status)}`
|
|
: "未设置"
|
|
}
|
|
/>
|
|
<InfoRow
|
|
label="来源"
|
|
value={formatTicketSource(ticket.source)}
|
|
/>
|
|
<InfoRow label="渠道" value={ticket.channel || "—"} />
|
|
<InfoRow
|
|
label="重开次数"
|
|
value={String(ticket.reopenedCount)}
|
|
/>
|
|
<InfoRow
|
|
label="创建时间"
|
|
value={
|
|
ticket.createdAt
|
|
? formatDateTime(ticket.createdAt)
|
|
: "—"
|
|
}
|
|
/>
|
|
<InfoRow
|
|
label="更新时间"
|
|
value={
|
|
ticket.updatedAt
|
|
? formatDateTime(ticket.updatedAt)
|
|
: "—"
|
|
}
|
|
/>
|
|
<InfoRow
|
|
label="截止时间"
|
|
value={
|
|
ticket.dueAt ? formatDateTime(ticket.dueAt) : "—"
|
|
}
|
|
/>
|
|
<InfoRow
|
|
label="首次响应"
|
|
value={
|
|
ticket.firstResponseAt
|
|
? formatDateTime(ticket.firstResponseAt)
|
|
: "—"
|
|
}
|
|
/>
|
|
</DetailSection>
|
|
|
|
<DetailSection
|
|
title="客户信息"
|
|
collapsible
|
|
collapsed={Boolean(collapsedSections.customer)}
|
|
onToggle={() => toggleSection("customer")}
|
|
className="px-4 pt-4 lg:px-6"
|
|
contentClassName="text-sm"
|
|
>
|
|
<TicketCustomerPanel
|
|
ticketId={ticket.id}
|
|
customerId={ticket.customerId}
|
|
onRefresh={loadDetail}
|
|
/>
|
|
</DetailSection>
|
|
|
|
<DetailSection
|
|
title="SLA 信息"
|
|
collapsible
|
|
collapsed={Boolean(collapsedSections.sla)}
|
|
onToggle={() => toggleSection("sla")}
|
|
className="px-4 pt-4 lg:px-6"
|
|
contentClassName="space-y-3 text-sm"
|
|
>
|
|
{ticket.sla?.length ? (
|
|
ticket.sla.map((sla) => (
|
|
<SurfacePanel key={sla.slaType} className="p-2.5">
|
|
<div className="font-medium">
|
|
{sla.slaType === "first_response"
|
|
? "首次响应"
|
|
: "解决时效"}
|
|
</div>
|
|
<div className="mt-1 text-muted-foreground">
|
|
目标:{sla.targetMinutes} 分钟
|
|
</div>
|
|
<div className="mt-1 text-muted-foreground">
|
|
状态:{formatSLAStatus(sla.status)}
|
|
</div>
|
|
<div className="mt-1 text-muted-foreground">
|
|
已耗时:{sla.elapsedMin} 分钟
|
|
</div>
|
|
{sla.breachedAt ? (
|
|
<div className="mt-1 text-muted-foreground">
|
|
超时于:{formatDateTime(sla.breachedAt)}
|
|
</div>
|
|
) : null}
|
|
</SurfacePanel>
|
|
))
|
|
) : (
|
|
<div className="rounded-lg border border-amber-200 bg-amber-50/70 p-3 text-sm text-amber-900">
|
|
当前工单未生成 SLA 记录,请检查工单 SLA
|
|
配置;如果系统仍在使用默认策略,也需要确认配置是否已补齐。
|
|
<Link
|
|
href="/dashboard/ticket-priorities"
|
|
className="ml-1 font-medium underline underline-offset-4"
|
|
>
|
|
前往配置优先级
|
|
</Link>
|
|
</div>
|
|
)}
|
|
</DetailSection>
|
|
|
|
<DetailSection
|
|
title="解决信息"
|
|
collapsible
|
|
collapsed={Boolean(collapsedSections.resolution)}
|
|
onToggle={() => toggleSection("resolution")}
|
|
className="px-4 pt-4 lg:px-6"
|
|
contentClassName="space-y-0 text-sm"
|
|
>
|
|
<InfoRow
|
|
label="解决码"
|
|
value={
|
|
ticket.resolutionCodeName ||
|
|
ticket.resolutionCode ||
|
|
"—"
|
|
}
|
|
/>
|
|
<InfoRow
|
|
label="解决说明"
|
|
value={ticket.resolutionSummary || "—"}
|
|
/>
|
|
<InfoRow
|
|
label="关闭原因"
|
|
value={ticket.closeReason || "—"}
|
|
/>
|
|
<InfoRow
|
|
label="解决时间"
|
|
value={
|
|
ticket.resolvedAt
|
|
? formatDateTime(ticket.resolvedAt)
|
|
: "—"
|
|
}
|
|
/>
|
|
<InfoRow
|
|
label="关闭时间"
|
|
value={
|
|
ticket.closedAt
|
|
? formatDateTime(ticket.closedAt)
|
|
: "—"
|
|
}
|
|
/>
|
|
</DetailSection>
|
|
|
|
<DetailSection
|
|
title="来源关联"
|
|
collapsible
|
|
collapsed={Boolean(collapsedSections.source)}
|
|
onToggle={() => toggleSection("source")}
|
|
className="px-4 pt-4 lg:px-6"
|
|
contentClassName="space-y-3 text-sm"
|
|
>
|
|
<InfoRow
|
|
label="关联会话"
|
|
value={
|
|
ticket.conversationId
|
|
? `#${ticket.conversationId}`
|
|
: "未关联"
|
|
}
|
|
/>
|
|
{sourceConversation ? (
|
|
<SurfacePanel className="p-2.5">
|
|
<div className="text-sm font-medium">
|
|
{sourceConversation.subject || "未命名会话"}
|
|
</div>
|
|
<div className="mt-2 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
|
<span>
|
|
状态:
|
|
{getEnumLabel(
|
|
IMConversationStatusLabels,
|
|
sourceConversation.status as IMConversationStatus,
|
|
)}
|
|
</span>
|
|
<span>
|
|
处理人:
|
|
{sourceConversation.currentAssigneeName ||
|
|
"未指派"}
|
|
</span>
|
|
</div>
|
|
<div className="mt-2 text-xs leading-6 text-muted-foreground">
|
|
{sourceConversation.lastMessageSummary ||
|
|
"暂无最近消息摘要"}
|
|
</div>
|
|
<div className="mt-2 text-xs text-muted-foreground">
|
|
最近活跃:
|
|
{sourceConversation.lastActiveAt
|
|
? formatDateTime(sourceConversation.lastActiveAt)
|
|
: "—"}
|
|
</div>
|
|
</SurfacePanel>
|
|
) : null}
|
|
{ticket.conversationId ? (
|
|
<Button
|
|
variant="outline"
|
|
className="w-full"
|
|
onClick={() => router.push("/dashboard/conversations")}
|
|
>
|
|
<RotateCcwIcon className="size-4" />
|
|
前往会话工作台
|
|
</Button>
|
|
) : null}
|
|
</DetailSection>
|
|
|
|
<DetailSection
|
|
title="关联工单"
|
|
collapsible
|
|
collapsed={Boolean(collapsedSections.related)}
|
|
onToggle={() => toggleSection("related")}
|
|
className="px-4 pt-4 lg:px-6"
|
|
action={
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setRelationDialogOpen(true)}
|
|
>
|
|
<PlusIcon className="size-4" />
|
|
新增关联
|
|
</Button>
|
|
}
|
|
contentClassName="space-y-3 text-sm"
|
|
>
|
|
{childProgress.total > 0 ? (
|
|
<div className="rounded-lg border border-blue-200 bg-blue-50/70 p-3">
|
|
<div className="text-sm font-medium text-blue-900">
|
|
子工单进展
|
|
</div>
|
|
<div className="mt-2 flex flex-wrap gap-2 text-xs text-blue-900">
|
|
<span>总数:{childProgress.total}</span>
|
|
<span>已完成:{childProgress.completed}</span>
|
|
<span>进行中:{childProgress.active}</span>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
{detail?.relatedTickets?.length ? (
|
|
detail.relatedTickets.map((relation) => (
|
|
<SurfacePanel key={relation.id} className="p-2.5">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0 space-y-1">
|
|
<div className="text-xs text-muted-foreground">
|
|
{ticketRelationLabel(relation.relationType)}
|
|
</div>
|
|
<div className="truncate font-medium">
|
|
{relation.relatedTicketNo ||
|
|
`#${relation.relatedTicketId}`}
|
|
</div>
|
|
<div className="text-sm text-muted-foreground">
|
|
{relation.relatedTicketTitle || "未命名工单"}
|
|
</div>
|
|
</div>
|
|
<div className="flex shrink-0 gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() =>
|
|
router.push(
|
|
`/tickets/detail?id=${relation.relatedTicketId}`,
|
|
)
|
|
}
|
|
>
|
|
<ExternalLinkIcon className="size-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
disabled={saving}
|
|
onClick={() =>
|
|
void handleDeleteRelation(relation.id)
|
|
}
|
|
>
|
|
<XIcon className="size-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className="mt-2 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
|
{relation.relationType === "child" ? (
|
|
<Badge
|
|
variant="outline"
|
|
className={
|
|
isDoneTicketStatus(
|
|
relation.relatedTicketStatus,
|
|
)
|
|
? "border-emerald-300 bg-emerald-50 text-emerald-800"
|
|
: "border-orange-300 bg-orange-50 text-orange-800"
|
|
}
|
|
>
|
|
{isDoneTicketStatus(
|
|
relation.relatedTicketStatus,
|
|
)
|
|
? "子单已完成"
|
|
: "子单处理中"}
|
|
</Badge>
|
|
) : null}
|
|
<span>
|
|
状态:
|
|
{ticketStatusLabel(
|
|
relation.relatedTicketStatus || "",
|
|
)}
|
|
</span>
|
|
<span>
|
|
团队:{relation.currentTeamName || "未分组"}
|
|
</span>
|
|
<span>
|
|
处理人:
|
|
{relation.currentAssigneeName || "未指派"}
|
|
</span>
|
|
</div>
|
|
<div className="mt-2 text-xs text-muted-foreground">
|
|
最近更新:
|
|
{relation.updatedAt
|
|
? formatDateTime(relation.updatedAt)
|
|
: "—"}
|
|
</div>
|
|
</SurfacePanel>
|
|
))
|
|
) : (
|
|
<div className="text-muted-foreground">
|
|
暂无关联工单
|
|
</div>
|
|
)}
|
|
</DetailSection>
|
|
|
|
<DetailSection
|
|
title="协作人"
|
|
collapsible
|
|
collapsed={Boolean(collapsedSections.collaborators)}
|
|
onToggle={() => toggleSection("collaborators")}
|
|
className="px-4 pt-4 lg:px-6"
|
|
action={
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setCollaboratorDialogOpen(true)}
|
|
>
|
|
<PlusIcon className="size-4" />
|
|
新增协作人
|
|
</Button>
|
|
}
|
|
contentClassName="space-y-3 text-sm"
|
|
>
|
|
{detail?.collaborators?.length ? (
|
|
detail.collaborators.map((collaborator) => (
|
|
<SurfacePanel
|
|
key={collaborator.id}
|
|
className="flex items-center justify-between gap-3 p-2.5"
|
|
>
|
|
<div className="min-w-0">
|
|
<div className="font-medium">
|
|
{collaborator.userName ||
|
|
`用户#${collaborator.userId}`}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{collaborator.teamName || "未分组"}
|
|
</div>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
disabled={saving}
|
|
onClick={() =>
|
|
void handleDeleteCollaborator(collaborator.id)
|
|
}
|
|
>
|
|
<XIcon className="size-4" />
|
|
</Button>
|
|
</SurfacePanel>
|
|
))
|
|
) : (
|
|
<div className="text-muted-foreground">暂无协作人</div>
|
|
)}
|
|
</DetailSection>
|
|
|
|
<DetailSection
|
|
title="关注人"
|
|
collapsible
|
|
collapsed={Boolean(collapsedSections.watchers)}
|
|
onToggle={() => toggleSection("watchers")}
|
|
className="px-4 pt-4 lg:px-6"
|
|
contentClassName="space-y-3 text-sm"
|
|
>
|
|
{detail?.watchers?.length ? (
|
|
detail.watchers.map((watcher) => (
|
|
<InfoRow
|
|
key={watcher.id}
|
|
label={`用户#${watcher.userId}`}
|
|
value={watcher.userName || "未命名"}
|
|
/>
|
|
))
|
|
) : (
|
|
<div className="text-muted-foreground">暂无关注人</div>
|
|
)}
|
|
</DetailSection>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="min-w-0 flex-1 p-4 lg:p-6">
|
|
<div className="py-20 text-center text-muted-foreground">
|
|
工单不存在
|
|
</div>
|
|
</div>
|
|
)}
|
|
<TicketReplyDialog
|
|
open={replyDialogOpen}
|
|
ticketId={ticket?.id ?? null}
|
|
onOpenChange={setReplyDialogOpen}
|
|
onSuccess={loadDetail}
|
|
/>
|
|
<TicketAssignDialog
|
|
open={assignDialogOpen}
|
|
ticketId={ticket?.id ?? null}
|
|
currentTeamId={ticket?.currentTeamId}
|
|
currentAssigneeId={ticket?.currentAssigneeId}
|
|
onOpenChange={setAssignDialogOpen}
|
|
onSuccess={loadDetail}
|
|
/>
|
|
<TicketStatusDialog
|
|
open={statusDialogOpen}
|
|
ticketId={ticket?.id ?? null}
|
|
currentStatus={ticket?.status}
|
|
onOpenChange={setStatusDialogOpen}
|
|
onSuccess={loadDetail}
|
|
/>
|
|
<TicketReasonDialog
|
|
open={closeDialogOpen}
|
|
mode="close"
|
|
ticketId={ticket?.id ?? null}
|
|
defaultReason={ticket?.closeReason || "处理完成"}
|
|
onOpenChange={setCloseDialogOpen}
|
|
onSuccess={loadDetail}
|
|
/>
|
|
<TicketReasonDialog
|
|
open={reopenDialogOpen}
|
|
mode="reopen"
|
|
ticketId={ticket?.id ?? null}
|
|
defaultReason="客户有新反馈"
|
|
onOpenChange={setReopenDialogOpen}
|
|
onSuccess={loadDetail}
|
|
/>
|
|
<EditDialog
|
|
open={editDialogOpen}
|
|
saving={saving}
|
|
itemId={ticket?.id ?? null}
|
|
onOpenChange={setEditDialogOpen}
|
|
onSubmit={handleEditSubmit}
|
|
/>
|
|
<TicketRelationDialog
|
|
open={relationDialogOpen}
|
|
ticketId={ticket?.id ?? null}
|
|
onOpenChange={setRelationDialogOpen}
|
|
onSuccess={loadDetail}
|
|
/>
|
|
<TicketCollaboratorDialog
|
|
open={collaboratorDialogOpen}
|
|
ticketId={ticket?.id ?? null}
|
|
onOpenChange={setCollaboratorDialogOpen}
|
|
onSuccess={loadDetail}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function InfoRow({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div className="grid grid-cols-[78px_minmax(0,1fr)] items-start gap-3 py-1">
|
|
<span className="text-sm text-muted-foreground">{label}</span>
|
|
<span className="text-right text-sm text-foreground wrap-break-word">
|
|
{value}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<section
|
|
className={`border-b border-border/70 py-4 last:border-b-0 ${className ?? ""}`}
|
|
>
|
|
{title || description || action ? (
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="min-w-0 space-y-1">
|
|
<div className="flex items-center gap-2">
|
|
{collapsible ? (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-6 shrink-0"
|
|
onClick={onToggle}
|
|
aria-label={collapsed ? "展开面板" : "折叠面板"}
|
|
>
|
|
{collapsed ? (
|
|
<ChevronRightIcon className="size-4" />
|
|
) : (
|
|
<ChevronDownIcon className="size-4" />
|
|
)}
|
|
</Button>
|
|
) : null}
|
|
{title ? <h2 className="text-base font-medium">{title}</h2> : null}
|
|
</div>
|
|
{description ? (
|
|
<p className="text-sm leading-5 text-muted-foreground">
|
|
{description}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
{action ? <div className="shrink-0">{action}</div> : null}
|
|
</div>
|
|
) : null}
|
|
{collapsed ? null :<div className="pt-3"><div className={contentClassName}>{children}</div></div>}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function SurfacePanel({
|
|
className,
|
|
children,
|
|
}: {
|
|
className?: string;
|
|
children: ReactNode;
|
|
}) {
|
|
return (
|
|
<div
|
|
className={`rounded-lg border border-border/60 bg-muted/20 ${className ?? ""}`}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|