1244 lines
42 KiB
TypeScript
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>
|
||
)
|
||
}
|