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

1244 lines
42 KiB
TypeScript

"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<TicketSummary>(emptySummary)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [dialogOpen, setDialogOpen] = useState(false)
const [editingItemId, setEditingItemId] = useState<number | null>(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<QuickViewKey>("all")
const [savedViews, setSavedViews] = useState<SavedTicketView[]>([])
const [activeSavedViewId, setActiveSavedViewId] = useState("all")
const [teams, setTeams] = useState<AdminAgentTeam[]>([])
const [agents, setAgents] = useState<AdminAgentProfile[]>([])
const [tags, setTags] = useState<TagTree[]>([])
const [priorities, setPriorities] = useState<TicketPriorityConfig[]>([])
const [actionLoadingId, setActionLoadingId] = useState<number | null>(null)
const [selectedTicketIds, setSelectedTicketIds] = useState<number[]>([])
const [assignDialogOpen, setAssignDialogOpen] = useState(false)
const [statusDialogOpen, setStatusDialogOpen] = useState(false)
const [closeDialogOpen, setCloseDialogOpen] = useState(false)
const [reopenDialogOpen, setReopenDialogOpen] = useState(false)
const [actionTicket, setActionTicket] = useState<TicketItem | null>(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<HTMLInputElement>) {
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<typeof createTicket>[0] | Parameters<typeof updateTicket>[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 (
<div className="min-h-0 flex-1 overflow-auto bg-muted/20 p-4 md:p-6">
<div className="flex w-full 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">工单</h1>
{/* <p className="mt-1 text-sm text-muted-foreground">
作为队列工作台处理异步问题、分派、流转与关闭
</p> */}
<div className="mt-1 flex flex-wrap gap-2 text-xs text-muted-foreground">
<span className="rounded bg-red-50 px-2 py-1 text-red-700">红色:已超时</span>
<span className="rounded bg-amber-50 px-2 py-1 text-amber-700">黄色:待分配</span>
<span className="rounded bg-orange-50 px-2 py-1 text-orange-700">橙色:即将超时</span>
</div>
</div>
<div className="flex items-center gap-2">
<div className="w-48">
<OptionCombobox
value={activeSavedViewId}
onChange={(value) => {
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 (
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-destructive"
onClick={() => void handleDeleteSavedView(viewId)}
>
<Trash2Icon className="size-4" />
</Button>
)
}}
/>
</div>
<Button variant="outline" onClick={handleSaveCurrentView}>
<SaveIcon className="size-4" />
保存视图
</Button>
<Button onClick={openCreateDialog}>
<PlusIcon className="size-4" />
新建工单
</Button>
</div>
</div>
<div className="rounded-lg border bg-background/80 p-2">
<div className="flex flex-wrap gap-2">
{quickViews.map((view) => {
const active = quickView === view.key
return (
<button
key={view.key}
type="button"
className={`inline-flex min-w-0 items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition ${
active
? "border-primary bg-primary/8 text-primary shadow-sm"
: "border-border bg-background text-foreground hover:border-primary/40 hover:bg-muted/60"
}`}
onClick={() => {
setQuickView(view.key)
resetToFirstPage()
}}
>
<span className="truncate font-medium">{view.label}</span>
<span
className={`rounded-full px-1.5 py-0.5 text-[11px] font-semibold tabular-nums ${
active ? "bg-primary/12 text-primary" : "bg-muted text-muted-foreground"
}`}
>
{view.count}
</span>
</button>
)
})}
</div>
</div>
<div className="grid gap-3 lg:grid-cols-4 xl:grid-cols-[minmax(0,1.5fr)_repeat(8,minmax(0,1fr))_auto]">
<div className="relative">
<SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-9"
value={keywordInput}
placeholder="搜索工单号、标题或描述"
onChange={(event) => setKeywordInput(event.target.value)}
onKeyDown={handleFilterKeyDown}
/>
</div>
<OptionCombobox
value={statusFilter}
onChange={(value) => {
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: "已取消" },
]}
/>
<OptionCombobox
value={tagFilter}
onChange={(value) => {
setTagFilter(value)
setQuickView("all")
resetToFirstPage()
}}
placeholder="全部标签"
options={tagOptions}
/>
<OptionCombobox
value={priorityFilter}
onChange={(value) => {
setPriorityFilter(value)
setQuickView("all")
resetToFirstPage()
}}
placeholder="全部优先级"
options={priorityOptions}
/>
<OptionCombobox
value={severityFilter}
onChange={(value) => {
setSeverityFilter(value)
setQuickView("all")
resetToFirstPage()
}}
placeholder="全部严重度"
options={[
{ value: "all", label: "全部严重度" },
{ value: "1", label: "轻微" },
{ value: "2", label: "一般" },
{ value: "3", label: "严重" },
]}
/>
<OptionCombobox
value={sourceFilter}
onChange={(value) => {
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: "系统" },
]}
/>
<OptionCombobox
value={teamFilter}
onChange={(value) => {
setTeamFilter(value)
setQuickView("all")
resetToFirstPage()
}}
placeholder="全部团队"
options={teamOptions}
/>
<OptionCombobox
value={assigneeFilter}
onChange={(value) => {
setAssigneeFilter(value)
setQuickView("all")
resetToFirstPage()
}}
placeholder="全部处理人"
options={agentOptions}
/>
<OptionCombobox
value={watchFilter}
onChange={(value) => {
setWatchFilter(value)
setQuickView("all")
resetToFirstPage()
}}
placeholder="全部关注"
options={[
{ value: "all", label: "全部关注" },
{ value: "watching", label: "我关注的" },
]}
/>
<div className="flex gap-2">
<Button variant="outline" onClick={applyFilters}>
查询
</Button>
<Button variant="outline" onClick={() => void refreshAll()}>
<RefreshCcwIcon className="size-4" />
</Button>
<Button variant="ghost" onClick={resetAllFilters}>
重置
</Button>
</div>
</div>
<div className="overflow-hidden rounded-lg border bg-background">
{selectedTicketIds.length > 0 ? (
<div className="flex flex-col gap-3 border-b bg-muted/30 px-4 py-3 lg:flex-row lg:items-center lg:justify-between">
<div className="text-sm text-muted-foreground">
已选择 <span className="font-medium text-foreground">{selectedTicketIds.length}</span> 张工单
</div>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setActionTicket(null)
setAssignDialogOpen(true)
}}
disabled={saving}
>
批量指派
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setActionTicket(null)
setStatusDialogOpen(true)
}}
disabled={saving}
>
批量改状态
</Button>
<Button
variant="outline"
size="sm"
onClick={() => void handleBatchWatch(true)}
disabled={saving}
>
批量关注
</Button>
<Button
variant="outline"
size="sm"
onClick={() => void handleBatchWatch(false)}
disabled={saving}
>
取消关注
</Button>
<Button variant="ghost" size="sm" onClick={() => setSelectedTicketIds([])} disabled={saving}>
清空选择
</Button>
</div>
</div>
) : null}
<Table>
<TableHeader className="bg-muted/35">
<TableRow>
<TableHead className="w-10">
<Checkbox
checked={allSelected}
onCheckedChange={(checked) => handleSelectAll(Boolean(checked))}
aria-label="全选工单"
/>
</TableHead>
<TableHead>工单</TableHead>
<TableHead>客户</TableHead>
<TableHead>标签</TableHead>
<TableHead>优先级</TableHead>
<TableHead>状态</TableHead>
<TableHead>SLA 风险</TableHead>
<TableHead>协作</TableHead>
<TableHead>处理人</TableHead>
<TableHead>团队</TableHead>
<TableHead>关注</TableHead>
<TableHead>更新时间</TableHead>
<TableHead className="text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={13} className="h-32 text-center text-muted-foreground">
加载中...
</TableCell>
</TableRow>
) : result.results.length > 0 ? (
displayResults.map((item) => (
<TableRow key={item.id} className={getTicketRowClassName(item)}>
<TableCell>
<Checkbox
checked={selectedTicketIds.includes(item.id)}
onCheckedChange={(checked) => toggleSelectTicket(item.id, Boolean(checked))}
aria-label={`选择工单 ${item.ticketNo}`}
/>
</TableCell>
<TableCell className="min-w-64">
<div className="space-y-1">
<div className="font-medium">{item.title}</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span>{item.ticketNo}</span>
<span>{item.source || "manual"}</span>
{item.currentAssigneeId <= 0 && !isClosedStatus(item.status) ? (
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-amber-700">
待分配
</span>
) : null}
{item.pendingReason && !isClosedStatus(item.status) ? (
<span className="rounded bg-muted px-1.5 py-0.5">{item.pendingReason}</span>
) : null}
</div>
</div>
</TableCell>
<TableCell>{item.customer?.name || "未绑定客户"}</TableCell>
<TableCell>
{item.tags && item.tags.length > 0 ? (
<div className="flex flex-wrap gap-1">
{item.tags.map((tag) => (
<Badge key={tag.id} variant="outline">
{tag.name}
</Badge>
))}
</div>
) : (
<span className="text-muted-foreground">未打标签</span>
)}
</TableCell>
<TableCell>
<TicketPriorityBadge priority={item.priority} priorityName={item.priorityName} />
</TableCell>
<TableCell>
<TicketStatusBadge status={item.status} />
</TableCell>
<TableCell>
<TicketSLABadge ticket={item} />
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{quickView === "participating" || quickView === "collaboration" ? (
<Badge variant="outline">协作中</Badge>
) : null}
{quickView === "mentioned" || quickView === "collaboration" ? (
<Badge variant="outline" className="border-amber-300 bg-amber-50 text-amber-800">
提及我
</Badge>
) : null}
{item.currentAssigneeId === currentUserId ? (
<Badge variant="outline" className="border-blue-300 bg-blue-50 text-blue-800">
负责人
</Badge>
) : null}
</div>
</TableCell>
<TableCell>{item.currentAssigneeName || "未指派"}</TableCell>
<TableCell>{item.currentTeamName || "未分组"}</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
disabled={actionLoadingId === item.id}
onClick={() => void handleWatchToggle(item)}
>
<StarIcon
className={`size-4 ${item.watchedByMe ? "fill-current text-amber-500" : ""}`}
/>
{item.watchedByMe ? "已关注" : "关注"}
</Button>
</TableCell>
<TableCell>{item.updatedAt ? formatDateTime(item.updatedAt) : "—"}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Link href={`/tickets/detail?id=${item.id}`} target="_blank" rel="noreferrer">
<Button variant="outline" size="sm">
<ClipboardListIcon className="size-4" />
详情
</Button>
</Link>
<Button variant="ghost" size="sm" onClick={() => openAssignDialog(item)}>
<ArrowRightLeftIcon className="size-4" />
指派
</Button>
<Button variant="ghost" size="sm" onClick={() => openStatusDialog(item)}>
<SquarePenIcon className="size-4" />
状态
</Button>
{item.status === "closed" ? (
<Button variant="ghost" size="sm" onClick={() => openReopenDialog(item)}>
重开
</Button>
) : (
<Button variant="ghost" size="sm" onClick={() => openCloseDialog(item)}>
<CircleOffIcon className="size-4" />
关闭
</Button>
)}
<Button variant="ghost" size="sm" onClick={() => openEditDialog(item)}>
编辑
</Button>
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={13} className="h-32 text-center text-muted-foreground">
暂无符合当前筛选条件的工单
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<ListPagination
page={result.page.page}
total={result.page.total}
limit={result.page.limit}
loading={loading}
onPageChange={(page) =>
setResult((current) => ({
...current,
page: { ...current.page, page },
}))
}
onLimitChange={(limit) =>
setResult((current) => ({
...current,
page: { ...current.page, page: 1, limit },
}))
}
/>
</div>
<EditDialog
open={dialogOpen}
saving={saving}
itemId={editingItemId}
onOpenChange={handleDialogOpenChange}
onSubmit={handleSubmit}
/>
<TicketAssignDialog
open={assignDialogOpen}
ticketId={actionTicket?.id ?? null}
ticketIds={actionTicket ? undefined : selectedTicketIds}
currentTeamId={actionTicket?.currentTeamId}
currentAssigneeId={actionTicket?.currentAssigneeId}
onOpenChange={(open) => {
setAssignDialogOpen(open)
if (!open) {
setActionTicket(null)
setSelectedTicketIds((current) => current)
}
}}
onSuccess={async () => {
setSelectedTicketIds([])
await refreshAll()
}}
/>
<TicketStatusDialog
open={statusDialogOpen}
ticketId={actionTicket?.id ?? null}
ticketIds={actionTicket ? undefined : selectedTicketIds}
currentStatus={actionTicket?.status}
onOpenChange={(open) => {
setStatusDialogOpen(open)
if (!open) {
setActionTicket(null)
}
}}
onSuccess={async () => {
setSelectedTicketIds([])
await refreshAll()
}}
/>
<TicketReasonDialog
open={closeDialogOpen}
mode="close"
ticketId={actionTicket?.id ?? null}
defaultReason={actionTicket?.closeReason || "处理完成"}
onOpenChange={(open) => {
setCloseDialogOpen(open)
if (!open) {
setActionTicket(null)
}
}}
onSuccess={refreshAll}
/>
<TicketReasonDialog
open={reopenDialogOpen}
mode="reopen"
ticketId={actionTicket?.id ?? null}
defaultReason="客户有新反馈"
onOpenChange={(open) => {
setReopenDialogOpen(open)
if (!open) {
setActionTicket(null)
}
}}
onSuccess={refreshAll}
/>
</div>
)
}