"use client" import { PlusIcon, RefreshCcwIcon, SearchIcon, SearchXIcon } from "lucide-react" import { useSearchParams } from "next/navigation" import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { toast } from "sonner" import { OptionCombobox, type ComboboxOption } from "@/components/option-combobox" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table" import { fetchAgentProfilesAll, fetchTagsAll, type AdminAgentProfile, type TagTree, } from "@/lib/api/admin" import { createTicket, fetchTicketSummary, fetchTickets, type CreateTicketPayload, type TicketItem, type TicketListQuery, type TicketStatus, type TicketSummary, } from "@/lib/api/ticket" import { cn, formatDateTime } from "@/lib/utils" import { EditDialog } from "./_components/edit" import { TicketDetailDialog } from "./_components/ticket-detail-dialog" import { TicketStatusBadge } from "./_components/ticket-status-badge" type QuickViewKey = | "all" | "pending" | "in_progress" | "done" | "unassigned" | "mine" | "stale" const emptySummary: TicketSummary = { all: 0, pending: 0, inProgress: 0, done: 0, unassigned: 0, mine: 0, stale: 0, } const assigneeAllOption: ComboboxOption = { value: "0", label: "全部负责人" } const tagAllOption: ComboboxOption = { value: "0", label: "全部标签" } const staleHourOptions: ComboboxOption[] = [ { value: "24", label: "24 小时" }, { value: "48", label: "48 小时" }, { value: "168", label: "168 小时" }, ] function buildTagOptions(nodes: TagTree[], parentPath = ""): ComboboxOption[] { const result: ComboboxOption[] = [] nodes.forEach((item) => { const currentPath = parentPath ? `${parentPath}/${item.name}` : item.name result.push({ value: String(item.id), label: currentPath, }) if (item.children.length > 0) { result.push(...buildTagOptions(item.children, currentPath)) } }) return result } function sourceLabel(source: string) { switch (source) { case "manual": return "手动" case "conversation": return "会话" default: return source || "-" } } function assigneeLabel(ticket: TicketItem) { if (ticket.currentAssigneeName) { return ticket.currentAssigneeName } if (ticket.currentAssigneeId > 0) { return `客服#${ticket.currentAssigneeId}` } return "未分配" } export default function TicketsPage() { const searchParams = useSearchParams() const [tickets, setTickets] = useState([]) const [summary, setSummary] = useState(emptySummary) const [quickView, setQuickView] = useState("all") const [keyword, setKeyword] = useState("") const [assigneeId, setAssigneeId] = useState("0") const [tagId, setTagId] = useState("0") const [staleHours, setStaleHours] = useState("24") const [assigneeOptions, setAssigneeOptions] = useState([assigneeAllOption]) const [tagOptions, setTagOptions] = useState([tagAllOption]) const [loading, setLoading] = useState(false) const [selectedTicketId, setSelectedTicketId] = useState(null) const [detailOpen, setDetailOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false) const [savingCreate, setSavingCreate] = useState(false) const loadSeqRef = useRef(0) const quickViews = useMemo( () => [ { key: "all", label: "全部工单", count: summary.all }, { key: "pending", label: "待处理", count: summary.pending }, { key: "in_progress", label: "处理中", count: summary.inProgress }, { key: "done", label: "已处理", count: summary.done }, { key: "unassigned", label: "待分配", count: summary.unassigned }, { key: "mine", label: "我的工单", count: summary.mine }, { key: "stale", label: "长时间未更新", count: summary.stale }, ] satisfies Array<{ key: QuickViewKey; label: string; count: number }>, [summary], ) const loadData = useCallback(async () => { const seq = loadSeqRef.current + 1 loadSeqRef.current = seq setLoading(true) try { const staleThreshold = Number(staleHours) const query: TicketListQuery = { page: 1, limit: 50, keyword: keyword.trim() || undefined, currentAssigneeId: assigneeId !== "0" ? Number(assigneeId) : undefined, tagId: tagId !== "0" ? Number(tagId) : undefined, } if (quickView === "pending" || quickView === "in_progress" || quickView === "done") { query.status = quickView as TicketStatus } if (quickView === "unassigned") { query.unassigned = 1 } if (quickView === "mine") { query.mine = 1 } if (quickView === "stale") { query.staleHours = staleThreshold } const [ticketData, summaryData] = await Promise.all([ fetchTickets(query), fetchTicketSummary({ staleHours: staleThreshold }), ]) if (loadSeqRef.current !== seq) { return } setTickets(Array.isArray(ticketData.results) ? ticketData.results : []) setSummary(summaryData ?? emptySummary) } catch (error) { if (loadSeqRef.current !== seq) { return } toast.error(error instanceof Error ? error.message : "加载工单失败") } finally { if (loadSeqRef.current === seq) { setLoading(false) } } }, [assigneeId, keyword, quickView, staleHours, tagId]) useEffect(() => { void loadData() }, [loadData]) useEffect(() => { const ticketId = Number(searchParams.get("ticketId")) if (ticketId > 0) { setSelectedTicketId(ticketId) setDetailOpen(true) } }, [searchParams]) useEffect(() => { let active = true Promise.all([fetchAgentProfilesAll(), fetchTagsAll()]) .then(([agents, tags]) => { if (!active) { return } setAssigneeOptions([ assigneeAllOption, ...(Array.isArray(agents) ? agents : []).map((agent: AdminAgentProfile) => ({ value: String(agent.userId), label: agent.displayName || agent.nickname || agent.username || `客服#${agent.userId}`, })), ]) setTagOptions([tagAllOption, ...buildTagOptions(Array.isArray(tags) ? tags : [])]) }) .catch((error) => { toast.error(error instanceof Error ? error.message : "加载筛选项失败") }) return () => { active = false } }, []) function resetFilters() { setQuickView("all") setKeyword("") setAssigneeId("0") setTagId("0") setStaleHours("24") } async function handleCreateTicket(payload: CreateTicketPayload) { setSavingCreate(true) try { await createTicket(payload) toast.success("工单已创建") setCreateOpen(false) await loadData() } catch (error) { toast.error(error instanceof Error ? error.message : "创建工单失败") } finally { setSavingCreate(false) } } return (
{quickViews.map((view) => ( ))}
setKeyword(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") { void loadData() } }} />
工单 状态 负责人 最后更新 操作 {tickets.length === 0 ? ( {loading ? "加载中..." : "暂无工单"} ) : ( tickets.map((ticket) => (
{ticket.title}
{ticket.ticketNo} {ticket.customer?.name || (ticket.customerId ? `客户#${ticket.customerId}` : "无客户")} {sourceLabel(ticket.source)} {ticket.channel ? {ticket.channel} : null}
{ticket.tags && ticket.tags.length > 0 ? (
{ticket.tags.slice(0, 3).map((tag) => ( {tag.name} ))} {ticket.tags.length > 3 ? ( +{ticket.tags.length - 3} ) : null}
) : null}
{assigneeLabel(ticket)} {ticket.updatedAt ? formatDateTime(ticket.updatedAt) : "-"}
)) )}
{ setDetailOpen(open) if (!open) { setSelectedTicketId(null) } }} onChanged={loadData} />
) }