Files
ai-ticket-platform/frontend/src/features/tickets/TicketListPage.tsx
2026-07-01 15:16:05 +08:00

274 lines
8.6 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.
import * as React from 'react'
import { ArrowRight, Plus } from 'lucide-react'
import { AppDataTable, type AppDataTableColumn } from '@/components/app/AppDataTable'
import { AppEmptyState } from '@/components/app/AppEmptyState'
import { AppPageHeader } from '@/components/app/AppPageHeader'
import { AppPriorityBadge } from '@/components/app/AppPriorityBadge'
import { AppStatusBadge } from '@/components/app/AppStatusBadge'
import { Button } from '@/components/ui/button'
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious
} from '@/components/ui/pagination'
import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { formatDateTime } from '@/lib/format'
import { cn } from '@/lib/utils'
import { listTickets } from './api'
import { TicketFilterBar } from './TicketFilterBar'
import { TicketMetricCards } from './TicketMetricCards'
import type { Ticket, TicketListResponse, TicketListView } from './types'
const PAGE_SIZE = 20
const viewTabs: Array<{ value: TicketListView; label: string }> = [
{ value: 'all', label: '全部工单' },
{ value: 'assigned_to_me', label: '我的待办' },
{ value: 'created_by_me', label: '我创建的' },
{ value: 'high_priority', label: '高优先级' }
]
const columns: AppDataTableColumn<Ticket>[] = [
{
key: 'ticket_no',
header: '编号',
cell: (ticket) => <span className="font-medium text-foreground">{ticket.ticket_no}</span>
},
{
key: 'title',
header: '标题',
cell: (ticket) => (
<div className="min-w-[240px]">
<p className="line-clamp-1 font-medium text-foreground">{ticket.title}</p>
<p className="mt-1 line-clamp-1 text-xs text-muted-foreground">
{ticket.requester?.display_name ? `请求人:${ticket.requester.display_name}` : ticket.environment || '暂无环境信息'}
</p>
</div>
)
},
{
key: 'status',
header: '状态',
cell: (ticket) => <AppStatusBadge status={ticket.status} />
},
{
key: 'priority',
header: '优先级',
cell: (ticket) => <AppPriorityBadge priority={ticket.priority} />
},
{
key: 'related_system',
header: '关联系统',
cell: (ticket) => <span className="text-muted-foreground">{ticket.related_system || '-'}</span>
},
{
key: 'impact_scope',
header: '影响范围',
cell: (ticket) => <span className="text-muted-foreground">{ticket.impact_scope || '-'}</span>
},
{
key: 'assignee',
header: '处理人',
cell: (ticket) => <span className="text-muted-foreground">{ticket.assignee?.display_name || '未分派'}</span>
},
{
key: 'created_at',
header: '创建时间',
cell: (ticket) => <span className="text-muted-foreground">{formatDateTime(ticket.created_at)}</span>
},
{
key: 'actions',
header: '操作',
cell: (ticket) => (
<Button
variant="ghost"
className="gap-1"
onClick={(event) => {
event.stopPropagation()
window.location.hash = `/tickets/${ticket.id}`
}}
>
查看详情
<ArrowRight className="size-4" />
</Button>
)
}
]
export function TicketListPage() {
const [result, setResult] = React.useState<TicketListResponse>({
items: [],
page: 1,
page_size: PAGE_SIZE,
total: 0,
total_pages: 0
})
const [view, setView] = React.useState<TicketListView>('all')
const [status, setStatus] = React.useState('all')
const [priority, setPriority] = React.useState('all')
const [relatedSystem, setRelatedSystem] = React.useState('')
const [q, setQ] = React.useState('')
const [error, setError] = React.useState('')
const [loading, setLoading] = React.useState(true)
const [page, setPage] = React.useState(1)
React.useEffect(() => {
setLoading(true)
listTickets({
page,
page_size: PAGE_SIZE,
view,
status: status === 'all' ? '' : status,
priority: priority === 'all' ? '' : priority,
related_system: relatedSystem,
q
})
.then((response) => {
setResult(response)
setError('')
})
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : '加载失败'))
.finally(() => setLoading(false))
}, [page, priority, q, relatedSystem, status, view])
function resetFilters() {
setStatus('all')
setPriority('all')
setRelatedSystem('')
setQ('')
setPage(1)
}
function updateView(nextView: string) {
setView(nextView as TicketListView)
setPage(1)
}
function updateStatus(value: string) {
setStatus(value)
setPage(1)
}
function updatePriority(value: string) {
setPriority(value)
setPage(1)
}
function updateRelatedSystem(value: string) {
setRelatedSystem(value)
setPage(1)
}
function updateQuery(value: string) {
setQ(value)
setPage(1)
}
function goToPage(nextPage: number) {
const target = Math.min(Math.max(nextPage, 1), Math.max(result.total_pages, 1))
setPage(target)
}
return (
<main className="min-h-screen bg-background p-6 text-foreground">
<div className="mx-auto flex max-w-7xl flex-col gap-5">
<AppPageHeader
title="工单中心"
description="集中查看、筛选和处理企业技术支持工单。"
actions={
<Button onClick={() => (window.location.hash = '/tickets/new')}>
<Plus className="size-4" />
新建工单
</Button>
}
/>
<TicketMetricCards tickets={result.items} />
<Tabs value={view} onValueChange={updateView}>
<TabsList>
{viewTabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<TicketFilterBar
q={q}
status={status}
priority={priority}
relatedSystem={relatedSystem}
onQChange={updateQuery}
onStatusChange={updateStatus}
onPriorityChange={updatePriority}
onRelatedSystemChange={updateRelatedSystem}
onReset={resetFilters}
/>
{error ? <AppEmptyState title="无法加载工单" description={error} /> : null}
{loading && !error ? (
<div className="grid gap-3 rounded-lg border border-border bg-surface p-4">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-8 w-64" />
</div>
) : null}
{!error && !loading ? (
<AppDataTable
columns={columns}
rows={result.items}
getRowKey={(ticket) => ticket.id}
emptyTitle="暂无工单"
emptyDescription="调整筛选条件,或创建第一张工单后再查看。"
footer={
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<p className="text-sm text-muted-foreground">
共 {result.total} 条,当前第 {result.page} / {Math.max(result.total_pages, 1)} 页
</p>
<Pagination className="mx-0 w-auto justify-start md:justify-end">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
className={cn(result.page <= 1 && 'pointer-events-none opacity-50')}
onClick={(event) => {
event.preventDefault()
goToPage(result.page - 1)
}}
/>
</PaginationItem>
<PaginationItem>
<PaginationLink href="#" isActive onClick={(event) => event.preventDefault()}>
{result.page}
</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationNext
href="#"
className={cn(result.page >= result.total_pages && 'pointer-events-none opacity-50')}
onClick={(event) => {
event.preventDefault()
goToPage(result.page + 1)
}}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
}
onRowClick={(ticket) => (window.location.hash = `/tickets/${ticket.id}`)}
/>
) : null}
</div>
</main>
)
}