274 lines
8.6 KiB
TypeScript
274 lines
8.6 KiB
TypeScript
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>
|
||
)
|
||
}
|