feat: redesign ticket list experience
This commit is contained in:
@@ -1,19 +1,38 @@
|
||||
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 { AppFilterBar } from '@/components/app/AppFilterBar'
|
||||
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 { Card, CardContent } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
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 type { Ticket } from './types'
|
||||
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>[] = [
|
||||
{
|
||||
@@ -24,7 +43,14 @@ const columns: AppDataTableColumn<Ticket>[] = [
|
||||
{
|
||||
key: 'title',
|
||||
header: '标题',
|
||||
cell: (ticket) => <span className="line-clamp-1">{ticket.title}</span>
|
||||
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',
|
||||
@@ -46,6 +72,11 @@ const columns: AppDataTableColumn<Ticket>[] = [
|
||||
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: '创建时间',
|
||||
@@ -57,102 +88,182 @@ const columns: AppDataTableColumn<Ticket>[] = [
|
||||
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 [tickets, setTickets] = React.useState<Ticket[]>([])
|
||||
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(() => {
|
||||
listTickets({ status: status === 'all' ? '' : status, q })
|
||||
.then(setTickets)
|
||||
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 : '加载失败'))
|
||||
}, [status, q])
|
||||
|
||||
const visibleTickets = priority === 'all' ? tickets : tickets.filter((ticket) => ticket.priority === priority)
|
||||
const metrics = [
|
||||
{ label: '待处理', value: tickets.filter((ticket) => ticket.status === 'pending').length },
|
||||
{ label: '处理中', value: tickets.filter((ticket) => ticket.status === 'in_progress').length },
|
||||
{ label: '待确认', value: tickets.filter((ticket) => ticket.status === 'pending_confirm').length },
|
||||
{ label: '已解决', value: tickets.filter((ticket) => ticket.status === 'resolved' || ticket.status === 'closed').length }
|
||||
]
|
||||
.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-4">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-5">
|
||||
<AppPageHeader
|
||||
title="工单中心"
|
||||
description="统一查看、筛选和处理技术支持工单。"
|
||||
actions={<Button onClick={() => (window.location.hash = '/tickets/new')}>新建工单</Button>}
|
||||
description="集中查看、筛选和处理企业技术支持工单。"
|
||||
actions={
|
||||
<Button onClick={() => (window.location.hash = '/tickets/new')}>
|
||||
<Plus className="size-4" />
|
||||
新建工单
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
{metrics.map((metric) => (
|
||||
<Card key={metric.label}>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">{metric.label}</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-foreground">{metric.value}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<TicketMetricCards tickets={result.items} />
|
||||
|
||||
<AppFilterBar actions={<Button variant="outline" onClick={resetFilters}>重置筛选</Button>}>
|
||||
<Input onChange={(event) => setQ(event.target.value)} placeholder="搜索工单编号或标题" value={q} />
|
||||
<Select onValueChange={setStatus} value={status}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="全部状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="pending">待处理</SelectItem>
|
||||
<SelectItem value="in_progress">处理中</SelectItem>
|
||||
<SelectItem value="pending_confirm">待确认</SelectItem>
|
||||
<SelectItem value="resolved">已解决</SelectItem>
|
||||
<SelectItem value="closed">已关闭</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select onValueChange={setPriority} value={priority}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="全部优先级" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部优先级</SelectItem>
|
||||
<SelectItem value="low">低</SelectItem>
|
||||
<SelectItem value="medium">中</SelectItem>
|
||||
<SelectItem value="high">高</SelectItem>
|
||||
<SelectItem value="urgent">紧急</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</AppFilterBar>
|
||||
<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}
|
||||
{!error ? (
|
||||
{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={visibleTickets}
|
||||
rows={result.items}
|
||||
getRowKey={(ticket) => ticket.id}
|
||||
emptyTitle="暂无工单"
|
||||
emptyDescription="创建第一张工单后,列表会显示在这里。"
|
||||
footer={<p className="text-sm text-muted-foreground">当前显示 {visibleTickets.length} 条工单</p>}
|
||||
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}
|
||||
|
||||
Reference in New Issue
Block a user