Files
ai-agent/web/app/dashboard/tickets/detail/page.tsx
T
2026-04-24 12:01:30 +08:00

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>
);
}