Files
booschat/web/app/(console)/ticket-risk/page.tsx
T
mlogclub efe801b8bf Init
2026-04-09 10:01:23 +08:00

327 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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="/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>
)
}