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

327 lines
12 KiB
TypeScript

"use client"
import Link from "next/link"
import { AlertTriangleIcon, CircleDashedIcon, RefreshCcwIcon, TimerResetIcon, WrenchIcon } from "lucide-react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { toast } from "sonner"
import { OptionCombobox } from "@/components/option-combobox"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
fetchAgentTeamsAll,
type AdminAgentTeam,
} from "@/lib/api/admin"
import {
fetchTicketRiskOverview,
fetchTicketRiskList,
type TicketItem,
type TicketRiskOverview,
} from "@/lib/api/ticket"
import { formatDateTime } from "@/lib/utils"
import { TicketPriorityBadge } from "../tickets/_components/ticket-priority-badge"
import { TicketSLABadge } from "../tickets/_components/ticket-sla-badge"
import { TicketStatusBadge } from "../tickets/_components/ticket-status-badge"
type RiskTableProps = {
title: string
description: string
items: TicketItem[]
emptyText: string
}
function RiskTable({ title, description, items, emptyText }: RiskTableProps) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>工单</TableHead>
<TableHead>分类</TableHead>
<TableHead>优先级</TableHead>
<TableHead>状态</TableHead>
<TableHead>SLA</TableHead>
<TableHead>处理人</TableHead>
<TableHead>更新时间</TableHead>
<TableHead className="text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.length > 0 ? (
items.map((item) => (
<TableRow key={item.id}>
<TableCell className="min-w-64">
<div className="space-y-1">
<div className="font-medium">{item.title}</div>
<div className="text-xs text-muted-foreground">{item.ticketNo}</div>
</div>
</TableCell>
<TableCell>
{item.tags && item.tags.length > 0
? item.tags.map((tag) => tag.name).join(" / ")
: "未打标签"}
</TableCell>
<TableCell>
<TicketPriorityBadge priority={item.priority} priorityName={item.priorityName} />
</TableCell>
<TableCell>
<TicketStatusBadge status={item.status} />
</TableCell>
<TableCell>
<TicketSLABadge ticket={item} />
</TableCell>
<TableCell>{item.currentAssigneeName || "未指派"}</TableCell>
<TableCell>{item.updatedAt ? formatDateTime(item.updatedAt) : "—"}</TableCell>
<TableCell className="text-right">
<Link href={`/tickets/detail?id=${item.id}`} target="_blank" rel="noreferrer">
<Button variant="outline" size="sm">
查看详情
</Button>
</Link>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={8} className="h-24 text-center text-muted-foreground">
{emptyText}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
)
}
export default function TicketRiskPage() {
const [loading, setLoading] = useState(true)
const [overview, setOverview] = useState<TicketRiskOverview | null>(null)
const [overdueTickets, setOverdueTickets] = useState<TicketItem[]>([])
const [highRiskTickets, setHighRiskTickets] = useState<TicketItem[]>([])
const [unassignedTickets, setUnassignedTickets] = useState<TicketItem[]>([])
const [pendingInternalTickets, setPendingInternalTickets] = useState<TicketItem[]>([])
const [teams, setTeams] = useState<AdminAgentTeam[]>([])
const [teamFilter, setTeamFilter] = useState("all")
const [riskWindow, setRiskWindow] = useState("240")
useEffect(() => {
void (async () => {
try {
const data = await fetchAgentTeamsAll()
setTeams(Array.isArray(data) ? data : [])
} catch {
setTeams([])
}
})()
}, [])
const loadData = useCallback(async () => {
setLoading(true)
try {
const currentTeamId = teamFilter === "all" ? undefined : Number(teamFilter)
const riskMinutes = Number(riskWindow)
const [overviewData, overdueData, highRiskData, unassignedData, pendingInternalData] =
await Promise.all([
fetchTicketRiskOverview({ currentTeamId, riskWindowMins: riskMinutes }),
fetchTicketRiskList({ riskType: "overdue", currentTeamId, riskWindowMins: riskMinutes, page: 1, limit: 10 }),
fetchTicketRiskList({ riskType: "high_risk", currentTeamId, riskWindowMins: riskMinutes, page: 1, limit: 10 }),
fetchTicketRiskList({ riskType: "unassigned", currentTeamId, riskWindowMins: riskMinutes, page: 1, limit: 10 }),
fetchTicketRiskList({ riskType: "pending_internal", currentTeamId, riskWindowMins: riskMinutes, page: 1, limit: 10 }),
])
setOverview(overviewData)
setOverdueTickets(Array.isArray(overdueData.results) ? overdueData.results : [])
setHighRiskTickets(Array.isArray(highRiskData.results) ? highRiskData.results : [])
setUnassignedTickets(Array.isArray(unassignedData.results) ? unassignedData.results : [])
setPendingInternalTickets(
Array.isArray(pendingInternalData.results) ? pendingInternalData.results : [],
)
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载 SLA 风险页失败")
} finally {
setLoading(false)
}
}, [riskWindow, teamFilter])
useEffect(() => {
void loadData()
}, [loadData])
const cards = useMemo(
() => [
{
title: "已超时",
description: "解决 SLA 已经 breach 的工单",
value: overview?.overdue ?? 0,
icon: AlertTriangleIcon,
tone: "text-red-700 bg-red-500/10",
},
{
title: `${Number(riskWindow) / 60} 小时内到期`,
description: "建议组长优先盯防的风险队列",
value: overview?.highRisk ?? 0,
icon: TimerResetIcon,
tone: "text-orange-700 bg-orange-500/10",
},
{
title: "待分配",
description: "目前还没有明确负责人的工单",
value: overview?.unassigned ?? 0,
icon: CircleDashedIcon,
tone: "text-amber-700 bg-amber-500/10",
},
{
title: "待内部处理",
description: "等待内部团队协作处理的工单",
value: overview?.pendingInternal ?? 0,
icon: WrenchIcon,
tone: "text-blue-700 bg-blue-500/10",
},
],
[overview, riskWindow],
)
return (
<div className="min-h-0 flex-1 overflow-auto bg-muted/20 p-4 md:p-6">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<div>
<h1 className="text-xl font-semibold">SLA 风险运营</h1>
<p className="mt-1 text-sm text-muted-foreground">
给主管和组长使用的风险盯防页,优先查看超时、临近超时和待分配工单
</p>
</div>
<div className="flex gap-2">
<div className="w-44">
<OptionCombobox
value={teamFilter}
onChange={setTeamFilter}
placeholder="全部团队"
options={[
{ value: "all", label: "全部团队" },
...teams.map((team) => ({ value: String(team.id), label: team.name })),
]}
/>
</div>
<div className="w-44">
<OptionCombobox
value={riskWindow}
onChange={setRiskWindow}
placeholder="风险时间窗"
options={[
{ value: "60", label: "1 小时内" },
{ value: "240", label: "4 小时内" },
{ value: "1440", label: "24 小时内" },
]}
/>
</div>
<Link href="/dashboard/tickets">
<Button variant="outline">前往工单工作台</Button>
</Link>
<Button variant="outline" onClick={() => void loadData()} disabled={loading}>
<RefreshCcwIcon className="size-4" />
刷新
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{cards.map((item) => {
const Icon = item.icon
return (
<Card key={item.title}>
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-3">
<div className="space-y-1">
<CardTitle className="text-sm font-medium">{item.title}</CardTitle>
<CardDescription>{item.description}</CardDescription>
</div>
<div className={`rounded-full p-2 ${item.tone}`}>
<Icon className="size-4" />
</div>
</CardHeader>
<CardContent>
<div className="text-3xl font-semibold tracking-tight">
{loading ? "..." : item.value.toLocaleString()}
</div>
</CardContent>
</Card>
)
})}
</div>
<RiskTable
title="已超时工单"
description="需要立即处理或升级的高风险工单"
items={overdueTickets}
emptyText="当前没有已超时工单"
/>
<RiskTable
title="4 小时内到期"
description="建议优先处理,避免进入超时队列"
items={highRiskTickets}
emptyText="当前没有临近超时工单"
/>
<Card>
<CardHeader>
<CardTitle className="text-base">滞留原因</CardTitle>
<CardDescription>帮助主管快速判断风险是由分配、协作还是 SLA 配置问题造成</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{(overview?.reasons?.length ?? 0) > 0 ? (
overview?.reasons?.map((item) => (
<div key={item.code} className="rounded-lg border bg-muted/20 p-4">
<div className="text-sm font-medium">{item.title}</div>
<div className="mt-2 text-2xl font-semibold">{item.count.toLocaleString()}</div>
<div className="mt-2 text-xs leading-6 text-muted-foreground">{item.description}</div>
</div>
))
) : (
<div className="text-sm text-muted-foreground">暂无滞留原因数据</div>
)}
</div>
</CardContent>
</Card>
<div className="grid gap-4 xl:grid-cols-2">
<RiskTable
title="待分配工单"
description="进入队列但尚未明确负责人的工单"
items={unassignedTickets}
emptyText="当前没有待分配工单"
/>
<RiskTable
title="待内部处理"
description="需要内部团队介入,容易长期滞留的工单"
items={pendingInternalTickets}
emptyText="当前没有待内部处理工单"
/>
</div>
</div>
</div>
)
}