"use client" import Link from "next/link" import { ArrowRightLeftIcon, CircleOffIcon, ClipboardListIcon, PlusIcon, RefreshCcwIcon, SearchIcon, SaveIcon, Settings2Icon, SquarePenIcon, StarIcon, Trash2Icon, } from "lucide-react" import { useCallback, useEffect, useMemo, useState } from "react" import { toast } from "sonner" import { ListPagination } from "@/components/list-pagination" import { OptionCombobox } from "@/components/option-combobox" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" import { Input } from "@/components/ui/input" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table" import { fetchAgentProfilesAll, fetchAgentTeamsAll, type AdminAgentProfile, type AdminAgentTeam, fetchTagsAll, type TagTree, } from "@/lib/api/admin" import { fetchTicketPriorityConfigsAll, type TicketPriorityConfig, } from "@/lib/api/ticket-config" import { createTicket, batchWatchTickets, deleteTicketView, fetchTicketViews, fetchTicketSummary, fetchTickets, saveTicketView, updateTicket, unwatchTicket, watchTicket, type Paging, type TicketSavedView, type TicketItem, type TicketSummary, } from "@/lib/api/ticket" import { readSession } from "@/lib/auth" import { formatDateTime } from "@/lib/utils" import { EditDialog } from "./_components/edit" import { TicketAssignDialog } from "./_components/ticket-assign-dialog" import { TicketPriorityBadge } from "./_components/ticket-priority-badge" import { TicketReasonDialog } from "./_components/ticket-reason-dialog" import { TicketSLABadge } from "./_components/ticket-sla-badge" import { TicketStatusDialog } from "./_components/ticket-status-dialog" import { TicketStatusBadge } from "./_components/ticket-status-badge" const emptyPaging: Paging = { page: 1, limit: 20, total: 0 } const emptySummary: TicketSummary = { all: 0, mine: 0, watching: 0, collaboration: 0, participating: 0, mentioned: 0, unassigned: 0, pendingCustomer: 0, pendingInternal: 0, overdue: 0, } type QuickViewKey = | "all" | "mine" | "collaboration" | "participating" | "mentioned" | "watching" | "unassigned" | "pending_customer" | "pending_internal" | "overdue" type SavedTicketView = { id: number name: string keywordInput: string keyword: string statusFilter: string priorityFilter: string severityFilter: string tagFilter: string teamFilter: string assigneeFilter: string sourceFilter: string watchFilter: string quickView: QuickViewKey } function buildTagOptions(nodes: TagTree[], parentPath = ""): Array<{ value: string; label: string }> { const result: Array<{ value: string; label: string }> = [] 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 isClosedStatus(status: string) { return status === "resolved" || status === "closed" || status === "cancelled" } function getResolveDeadlineTime(ticket: TicketItem) { if (!ticket.resolveDeadlineAt) { return null } const deadline = new Date(ticket.resolveDeadlineAt.replace(" ", "T")) if (Number.isNaN(deadline.getTime())) { return null } return deadline.getTime() } function isOverdueTicket(ticket: TicketItem) { const deadline = getResolveDeadlineTime(ticket) if (deadline === null || isClosedStatus(ticket.status)) { return false } return deadline < Date.now() } function isRiskTicket(ticket: TicketItem) { const deadline = getResolveDeadlineTime(ticket) if (deadline === null || isClosedStatus(ticket.status)) { return false } const remainingMinutes = Math.floor((deadline - Date.now()) / 60000) return remainingMinutes >= 0 && remainingMinutes <= 240 } function getTicketRowClassName(ticket: TicketItem) { if (isOverdueTicket(ticket)) { return "bg-red-50/80 hover:bg-red-50" } if (ticket.currentAssigneeId <= 0 && !isClosedStatus(ticket.status)) { return "bg-amber-50/70 hover:bg-amber-50" } if (isRiskTicket(ticket)) { return "bg-orange-50/60 hover:bg-orange-50" } return "" } function parseSavedTicketView(item: TicketSavedView): SavedTicketView | null { const filters = item.filters if (!filters || typeof filters !== "object") { return null } return { id: item.id, name: item.name, keywordInput: typeof filters.keywordInput === "string" ? filters.keywordInput : "", keyword: typeof filters.keyword === "string" ? filters.keyword : "", statusFilter: typeof filters.statusFilter === "string" ? filters.statusFilter : "all", priorityFilter: typeof filters.priorityFilter === "string" ? filters.priorityFilter : "all", severityFilter: typeof filters.severityFilter === "string" ? filters.severityFilter : "all", tagFilter: typeof filters.tagFilter === "string" ? filters.tagFilter : typeof filters.categoryFilter === "string" ? filters.categoryFilter : "all", teamFilter: typeof filters.teamFilter === "string" ? filters.teamFilter : "all", assigneeFilter: typeof filters.assigneeFilter === "string" ? filters.assigneeFilter : "all", sourceFilter: typeof filters.sourceFilter === "string" ? filters.sourceFilter : "all", watchFilter: typeof filters.watchFilter === "string" ? filters.watchFilter : "all", quickView: typeof filters.quickView === "string" ? (filters.quickView as QuickViewKey) : "all", } } export default function TicketsPage() { const [result, setResult] = useState<{ results: TicketItem[]; page: Paging }>({ results: [], page: emptyPaging, }) const [summary, setSummary] = useState(emptySummary) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [dialogOpen, setDialogOpen] = useState(false) const [editingItemId, setEditingItemId] = useState(null) const [keywordInput, setKeywordInput] = useState("") const [keyword, setKeyword] = useState("") const [statusFilter, setStatusFilter] = useState("all") const [priorityFilter, setPriorityFilter] = useState("all") const [severityFilter, setSeverityFilter] = useState("all") const [tagFilter, setTagFilter] = useState("all") const [teamFilter, setTeamFilter] = useState("all") const [assigneeFilter, setAssigneeFilter] = useState("all") const [sourceFilter, setSourceFilter] = useState("all") const [watchFilter, setWatchFilter] = useState("all") const [quickView, setQuickView] = useState("all") const [savedViews, setSavedViews] = useState([]) const [activeSavedViewId, setActiveSavedViewId] = useState("all") const [teams, setTeams] = useState([]) const [agents, setAgents] = useState([]) const [tags, setTags] = useState([]) const [priorities, setPriorities] = useState([]) const [actionLoadingId, setActionLoadingId] = useState(null) const [selectedTicketIds, setSelectedTicketIds] = useState([]) const [assignDialogOpen, setAssignDialogOpen] = useState(false) const [statusDialogOpen, setStatusDialogOpen] = useState(false) const [closeDialogOpen, setCloseDialogOpen] = useState(false) const [reopenDialogOpen, setReopenDialogOpen] = useState(false) const [actionTicket, setActionTicket] = useState(null) const currentUserId = readSession()?.user?.id ?? 0 const activeStatusFilter = useMemo(() => { if (quickView === "pending_customer") { return "pending_customer" } if (quickView === "pending_internal") { return "pending_internal" } return statusFilter === "all" ? undefined : statusFilter }, [quickView, statusFilter]) const activeWatchFilter = quickView === "watching" ? 1 : watchFilter === "watching" ? 1 : undefined const activeCollaborationFilter = quickView === "collaboration" ? 1 : undefined const activeCollaboratingFilter = quickView === "participating" ? 1 : undefined const activeMentionedFilter = quickView === "mentioned" ? 1 : undefined const activeMineFilter = quickView === "mine" ? 1 : undefined const activeUnassignedFilter = quickView === "unassigned" ? 1 : undefined const activeOverdueFilter = quickView === "overdue" ? 1 : undefined const loadSummary = useCallback(async () => { try { const data = await fetchTicketSummary() setSummary(data) } catch (error) { toast.error(error instanceof Error ? error.message : "加载工单汇总失败") } }, []) const loadSavedViews = useCallback(async () => { try { const data = await fetchTicketViews() const views = Array.isArray(data) ? data .map(parseSavedTicketView) .filter((item): item is SavedTicketView => item !== null) : [] setSavedViews(views) } catch (error) { toast.error(error instanceof Error ? error.message : "加载保存视图失败") } }, []) const loadTickets = useCallback(async () => { setLoading(true) try { const data = await fetchTickets({ page: result.page.page, limit: result.page.limit, keyword: keyword.trim() || undefined, status: activeStatusFilter, priority: priorityFilter === "all" ? undefined : Number(priorityFilter), severity: severityFilter === "all" ? undefined : Number(severityFilter), tagId: tagFilter === "all" ? undefined : Number(tagFilter), currentTeamId: teamFilter === "all" ? undefined : Number(teamFilter), currentAssigneeId: assigneeFilter === "all" ? undefined : Number(assigneeFilter), source: sourceFilter === "all" ? undefined : sourceFilter, watching: activeWatchFilter, collaboration: activeCollaborationFilter, collaborating: activeCollaboratingFilter, mentioned: activeMentionedFilter, mine: activeMineFilter, unassigned: activeUnassignedFilter, overdue: activeOverdueFilter, }) setResult({ results: Array.isArray(data.results) ? data.results : [], page: data.page ?? emptyPaging, }) } catch (error) { toast.error(error instanceof Error ? error.message : "加载工单失败") } finally { setLoading(false) } }, [ activeMineFilter, activeOverdueFilter, activeStatusFilter, activeUnassignedFilter, activeWatchFilter, activeCollaborationFilter, activeCollaboratingFilter, activeMentionedFilter, assigneeFilter, tagFilter, keyword, priorityFilter, result.page.limit, result.page.page, severityFilter, sourceFilter, teamFilter, ]) useEffect(() => { void loadTickets() }, [loadTickets]) useEffect(() => { setSelectedTicketIds((current) => current.filter((ticketId) => result.results.some((item) => item.id === ticketId)), ) }, [result.results]) useEffect(() => { void loadSummary() }, [loadSummary]) useEffect(() => { void loadSavedViews() }, [loadSavedViews]) useEffect(() => { void (async () => { const [teamData, agentData, tagData, priorityData] = await Promise.all([ fetchAgentTeamsAll(), fetchAgentProfilesAll(), fetchTagsAll(), fetchTicketPriorityConfigsAll(), ]) setTeams(Array.isArray(teamData) ? teamData : []) setAgents(Array.isArray(agentData) ? agentData : []) setTags(Array.isArray(tagData) ? tagData : []) setPriorities(Array.isArray(priorityData) ? priorityData : []) })() }, []) function resetToFirstPage() { setResult((current) => ({ ...current, page: { ...current.page, page: 1 }, })) } function applyFilters() { setKeyword(keywordInput) setActiveSavedViewId("all") resetToFirstPage() } function buildCurrentView(name: string, existingId?: number): SavedTicketView { return { id: existingId ?? 0, name, keywordInput, keyword, statusFilter, priorityFilter, severityFilter, tagFilter, teamFilter, assigneeFilter, sourceFilter, watchFilter, quickView, } } function applySavedView(view: SavedTicketView) { setKeywordInput(view.keywordInput) setKeyword(view.keyword) setStatusFilter(view.statusFilter) setPriorityFilter(view.priorityFilter) setSeverityFilter(view.severityFilter) setTagFilter(view.tagFilter) setTeamFilter(view.teamFilter) setAssigneeFilter(view.assigneeFilter) setSourceFilter(view.sourceFilter) setWatchFilter(view.watchFilter) setQuickView(view.quickView) setActiveSavedViewId(String(view.id)) setResult((current) => ({ ...current, page: { ...current.page, page: 1 }, })) } function resetAllFilters() { setKeywordInput("") setKeyword("") setStatusFilter("all") setPriorityFilter("all") setSeverityFilter("all") setTagFilter("all") setTeamFilter("all") setAssigneeFilter("all") setSourceFilter("all") setWatchFilter("all") setQuickView("all") setActiveSavedViewId("all") resetToFirstPage() } async function handleSaveCurrentView() { const currentView = activeSavedViewId === "all" ? null : savedViews.find((item) => String(item.id) === activeSavedViewId) ?? null const defaultName = currentView?.name ?? "" const name = window.prompt("输入视图名称", defaultName)?.trim() if (!name) { return } const nextView = buildCurrentView(name, currentView?.id) try { const saved = await saveTicketView({ id: nextView.id > 0 ? nextView.id : undefined, name: nextView.name, filters: { keywordInput: nextView.keywordInput, keyword: nextView.keyword, statusFilter: nextView.statusFilter, priorityFilter: nextView.priorityFilter, severityFilter: nextView.severityFilter, tagFilter: nextView.tagFilter, teamFilter: nextView.teamFilter, assigneeFilter: nextView.assigneeFilter, sourceFilter: nextView.sourceFilter, watchFilter: nextView.watchFilter, quickView: nextView.quickView, }, }) const parsed = parseSavedTicketView(saved) if (parsed) { setSavedViews((current) => { const exists = current.some((item) => item.id === parsed.id) return exists ? current.map((item) => (item.id === parsed.id ? parsed : item)) : [parsed, ...current] }) setActiveSavedViewId(String(parsed.id)) } toast.success(currentView ? "视图已更新" : "视图已保存") } catch (error) { toast.error(error instanceof Error ? error.message : "保存视图失败") } } async function handleDeleteSavedView(viewId: number) { const currentView = savedViews.find((item) => item.id === viewId) if (!currentView) { return } const confirmed = window.confirm(`确认删除视图「${currentView.name}」吗?`) if (!confirmed) { return } try { await deleteTicketView(currentView.id) setSavedViews((current) => current.filter((item) => item.id !== currentView.id)) if (activeSavedViewId === String(currentView.id)) { setActiveSavedViewId("all") } toast.success("视图已删除") } catch (error) { toast.error(error instanceof Error ? error.message : "删除视图失败") } } function handleFilterKeyDown(event: React.KeyboardEvent) { if (event.key !== "Enter") { return } event.preventDefault() applyFilters() } function openCreateDialog() { setEditingItemId(null) setDialogOpen(true) } function openEditDialog(item: TicketItem) { setEditingItemId(item.id) setDialogOpen(true) } function openAssignDialog(item: TicketItem) { setActionTicket(item) setAssignDialogOpen(true) } function openStatusDialog(item: TicketItem) { setActionTicket(item) setStatusDialogOpen(true) } function openCloseDialog(item: TicketItem) { setActionTicket(item) setCloseDialogOpen(true) } function openReopenDialog(item: TicketItem) { setActionTicket(item) setReopenDialogOpen(true) } function handleDialogOpenChange(open: boolean) { if (saving) { return } if (!open) { setEditingItemId(null) } setDialogOpen(open) } async function refreshAll() { await Promise.all([loadSummary(), loadTickets()]) } async function handleSubmit( payload: Parameters[0] | Parameters[0], ) { if (saving) { return } setSaving(true) try { if ("ticketId" in payload) { await updateTicket(payload) toast.success("工单已更新") } else { await createTicket(payload) toast.success("工单已创建") } setDialogOpen(false) setEditingItemId(null) await refreshAll() } catch (error) { toast.error(error instanceof Error ? error.message : "保存工单失败") } finally { setSaving(false) } } async function handleWatchToggle(item: TicketItem) { setActionLoadingId(item.id) try { if (item.watchedByMe) { await unwatchTicket(item.id) toast.success("已取消关注") } else { await watchTicket(item.id) toast.success("已关注工单") } await refreshAll() } catch (error) { toast.error(error instanceof Error ? error.message : "更新关注状态失败") } finally { setActionLoadingId(null) } } function toggleSelectTicket(ticketId: number, checked: boolean) { setSelectedTicketIds((current) => { if (checked) { if (current.includes(ticketId)) { return current } return current.concat(ticketId) } return current.filter((item) => item !== ticketId) }) } function handleSelectAll(checked: boolean) { setSelectedTicketIds(checked ? result.results.map((item) => item.id) : []) } async function handleBatchWatch(watched: boolean) { if (selectedTicketIds.length === 0) { toast.error("请先选择工单") return } setSaving(true) try { await batchWatchTickets({ ticketIds: selectedTicketIds, watched }) toast.success(watched ? `已批量关注 ${selectedTicketIds.length} 张工单` : `已批量取消关注 ${selectedTicketIds.length} 张工单`) setSelectedTicketIds([]) await refreshAll() } catch (error) { toast.error(error instanceof Error ? error.message : "批量更新关注失败") } finally { setSaving(false) } } const tagOptions = [{ value: "all", label: "全部标签" }].concat( buildTagOptions(tags), ) const priorityOptions = [{ value: "all", label: "全部优先级" }].concat( priorities.map((priority) => ({ value: String(priority.id), label: priority.name, })), ) const teamOptions = [{ value: "all", label: "全部团队" }].concat( teams.map((team) => ({ value: String(team.id), label: team.name })), ) const agentOptions = [{ value: "all", label: "全部处理人" }].concat( agents.map((agent) => ({ value: String(agent.userId), label: agent.displayName || agent.nickname || agent.username || `客服#${agent.userId}`, })), ) const quickViews: Array<{ key: QuickViewKey label: string description: string count: number }> = [ { key: "all", label: "全部工单", description: "工单总量", count: summary.all }, { key: "mine", label: "我的工单", description: "当前指派给我", count: summary.mine }, { key: "collaboration", label: "协作相关", description: "我参与或提及我的工单", count: summary.collaboration }, { key: "participating", label: "我参与的", description: "我是协作人的工单", count: summary.participating }, { key: "mentioned", label: "提及我的", description: "内部备注里提到了我", count: summary.mentioned }, { key: "unassigned", label: "待分配", description: "尚未指派处理人", count: summary.unassigned }, { key: "watching", label: "我的关注", description: "我在跟进的工单", count: summary.watching }, { key: "pending_customer", label: "待客户反馈", description: "等待客户补充信息", count: summary.pendingCustomer, }, { key: "pending_internal", label: "待内部处理", description: "等待内部协作处理", count: summary.pendingInternal, }, { key: "overdue", label: "已超时", description: "解决 SLA 已超时", count: summary.overdue }, ] const allSelected = result.results.length > 0 && selectedTicketIds.length === result.results.length const displayResults = useMemo(() => { const list = [...result.results] list.sort((left, right) => { const leftOverdue = isOverdueTicket(left) const rightOverdue = isOverdueTicket(right) if (leftOverdue !== rightOverdue) { return leftOverdue ? -1 : 1 } if (quickView === "unassigned") { const leftUnassigned = left.currentAssigneeId <= 0 ? 1 : 0 const rightUnassigned = right.currentAssigneeId <= 0 ? 1 : 0 if (leftUnassigned !== rightUnassigned) { return rightUnassigned - leftUnassigned } } const leftDeadline = getResolveDeadlineTime(left) const rightDeadline = getResolveDeadlineTime(right) if (leftDeadline !== null || rightDeadline !== null) { if (leftDeadline === null) { return 1 } if (rightDeadline === null) { return -1 } if (leftDeadline !== rightDeadline) { return leftDeadline - rightDeadline } } if (left.priority !== right.priority) { return right.priority - left.priority } return (right.id ?? 0) - (left.id ?? 0) }) return list }, [quickView, result.results]) const savedViewOptions = useMemo( () => [{ value: "all", label: "筛选视图" }].concat( savedViews.map((item) => ({ value: String(item.id), label: item.name, })), ), [savedViews], ) return (

工单

{/*

作为队列工作台处理异步问题、分派、流转与关闭

*/}
红色:已超时 黄色:待分配 橙色:即将超时
{ if (value === "all") { setActiveSavedViewId("all") return } const nextView = savedViews.find((item) => String(item.id) === value) if (nextView) { applySavedView(nextView) } }} placeholder="保存视图" options={savedViewOptions} renderOptionAction={(option) => { if (option.value === "all") { return null } const viewId = Number(option.value) if (!Number.isFinite(viewId)) { return null } return ( ) }} />
{quickViews.map((view) => { const active = quickView === view.key return ( ) })}
setKeywordInput(event.target.value)} onKeyDown={handleFilterKeyDown} />
{ setStatusFilter(value) setQuickView("all") resetToFirstPage() }} placeholder="全部状态" options={[ { value: "all", label: "全部状态" }, { value: "new", label: "新建" }, { value: "open", label: "处理中" }, { value: "pending_customer", label: "待客户反馈" }, { value: "pending_internal", label: "待内部处理" }, { value: "resolved", label: "已解决" }, { value: "closed", label: "已关闭" }, { value: "cancelled", label: "已取消" }, ]} /> { setTagFilter(value) setQuickView("all") resetToFirstPage() }} placeholder="全部标签" options={tagOptions} /> { setPriorityFilter(value) setQuickView("all") resetToFirstPage() }} placeholder="全部优先级" options={priorityOptions} /> { setSeverityFilter(value) setQuickView("all") resetToFirstPage() }} placeholder="全部严重度" options={[ { value: "all", label: "全部严重度" }, { value: "1", label: "轻微" }, { value: "2", label: "一般" }, { value: "3", label: "严重" }, ]} /> { setSourceFilter(value) setQuickView("all") resetToFirstPage() }} placeholder="全部来源" options={[ { value: "all", label: "全部来源" }, { value: "manual", label: "手动创建" }, { value: "conversation", label: "会话转工单" }, { value: "email", label: "邮件" }, { value: "api", label: "API" }, { value: "system", label: "系统" }, ]} /> { setTeamFilter(value) setQuickView("all") resetToFirstPage() }} placeholder="全部团队" options={teamOptions} /> { setAssigneeFilter(value) setQuickView("all") resetToFirstPage() }} placeholder="全部处理人" options={agentOptions} /> { setWatchFilter(value) setQuickView("all") resetToFirstPage() }} placeholder="全部关注" options={[ { value: "all", label: "全部关注" }, { value: "watching", label: "我关注的" }, ]} />
{selectedTicketIds.length > 0 ? (
已选择 {selectedTicketIds.length} 张工单
) : null} handleSelectAll(Boolean(checked))} aria-label="全选工单" /> 工单 客户 标签 优先级 状态 SLA 风险 协作 处理人 团队 关注 更新时间 操作 {loading ? ( 加载中... ) : result.results.length > 0 ? ( displayResults.map((item) => ( toggleSelectTicket(item.id, Boolean(checked))} aria-label={`选择工单 ${item.ticketNo}`} />
{item.title}
{item.ticketNo} {item.source || "manual"} {item.currentAssigneeId <= 0 && !isClosedStatus(item.status) ? ( 待分配 ) : null} {item.pendingReason && !isClosedStatus(item.status) ? ( {item.pendingReason} ) : null}
{item.customer?.name || "未绑定客户"} {item.tags && item.tags.length > 0 ? (
{item.tags.map((tag) => ( {tag.name} ))}
) : ( 未打标签 )}
{quickView === "participating" || quickView === "collaboration" ? ( 协作中 ) : null} {quickView === "mentioned" || quickView === "collaboration" ? ( 提及我 ) : null} {item.currentAssigneeId === currentUserId ? ( 负责人 ) : null}
{item.currentAssigneeName || "未指派"} {item.currentTeamName || "未分组"} {item.updatedAt ? formatDateTime(item.updatedAt) : "—"}
{item.status === "closed" ? ( ) : ( )}
)) ) : ( 暂无符合当前筛选条件的工单 )}
setResult((current) => ({ ...current, page: { ...current.page, page }, })) } onLimitChange={(limit) => setResult((current) => ({ ...current, page: { ...current.page, page: 1, limit }, })) } />
{ setAssignDialogOpen(open) if (!open) { setActionTicket(null) setSelectedTicketIds((current) => current) } }} onSuccess={async () => { setSelectedTicketIds([]) await refreshAll() }} /> { setStatusDialogOpen(open) if (!open) { setActionTicket(null) } }} onSuccess={async () => { setSelectedTicketIds([]) await refreshAll() }} /> { setCloseDialogOpen(open) if (!open) { setActionTicket(null) } }} onSuccess={refreshAll} /> { setReopenDialogOpen(open) if (!open) { setActionTicket(null) } }} onSuccess={refreshAll} />
) }