feat: redesign ticket list experience

This commit is contained in:
hailin
2026-07-01 15:16:05 +08:00
parent 4a44f66505
commit 382237e9f5
6 changed files with 350 additions and 69 deletions

View File

@@ -0,0 +1,74 @@
import { RotateCcw } from 'lucide-react'
import { AppFilterBar } from '@/components/app/AppFilterBar'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
type TicketFilterBarProps = {
q: string
status: string
priority: string
relatedSystem: string
onQChange: (value: string) => void
onStatusChange: (value: string) => void
onPriorityChange: (value: string) => void
onRelatedSystemChange: (value: string) => void
onReset: () => void
}
export function TicketFilterBar({
q,
status,
priority,
relatedSystem,
onQChange,
onStatusChange,
onPriorityChange,
onRelatedSystemChange,
onReset
}: TicketFilterBarProps) {
return (
<AppFilterBar
actions={
<Button variant="outline" onClick={onReset}>
<RotateCcw className="size-4" />
重置筛选
</Button>
}
>
<Input onChange={(event) => onQChange(event.target.value)} placeholder="搜索编号、标题或描述" value={q} />
<Select onValueChange={onStatusChange} value={status}>
<SelectTrigger className="w-full">
<SelectValue placeholder="全部状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="pending_info">待补充</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={onPriorityChange} 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>
<Input
onChange={(event) => onRelatedSystemChange(event.target.value)}
placeholder="按关联系统筛选"
value={relatedSystem}
/>
</AppFilterBar>
)
}

View File

@@ -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}

View File

@@ -0,0 +1,63 @@
import { AlertTriangle, Clock3, Flame, RotateCcw } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import type { Ticket } from './types'
type TicketMetricCardsProps = {
tickets: Ticket[]
}
const metricConfig = [
{
label: '待处理',
description: '需要分派或开始处理',
icon: Clock3,
value: (tickets: Ticket[]) => tickets.filter((ticket) => ticket.status === 'pending').length
},
{
label: '处理中',
description: '技术支持正在跟进',
icon: RotateCcw,
value: (tickets: Ticket[]) => tickets.filter((ticket) => ticket.status === 'in_progress').length
},
{
label: '待确认',
description: '等待业务侧确认结果',
icon: AlertTriangle,
value: (tickets: Ticket[]) => tickets.filter((ticket) => ticket.status === 'pending_confirm').length
},
{
label: '高优先级',
description: '高或紧急优先级',
icon: Flame,
value: (tickets: Ticket[]) =>
tickets.filter((ticket) => ticket.priority === 'high' || ticket.priority === 'urgent').length
}
]
export function TicketMetricCards({ tickets }: TicketMetricCardsProps) {
return (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{metricConfig.map((metric) => {
const Icon = metric.icon
return (
<Card key={metric.label}>
<CardHeader className="grid grid-cols-[minmax(0,1fr)_auto] items-start">
<div>
<CardTitle className="text-sm">{metric.label}</CardTitle>
<p className="mt-1 text-xs text-muted-foreground">{metric.description}</p>
</div>
<div className="flex size-8 items-center justify-center rounded-md bg-muted text-muted-foreground">
<Icon className="size-4" />
</div>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold text-foreground">{metric.value(tickets)}</p>
</CardContent>
</Card>
)
})}
</div>
)
}

View File

@@ -1,13 +1,16 @@
import { apiRequest } from '@/lib/api'
import type { Ticket, TicketComment, TicketInput, TicketStatus } from './types'
import type { Ticket, TicketComment, TicketInput, TicketListParams, TicketListResponse, TicketStatus } from './types'
export function listTickets(params: { status?: string; q?: string } = {}) {
export function listTickets(params: TicketListParams = {}) {
const query = new URLSearchParams()
if (params.status) query.set('status', params.status)
if (params.q) query.set('q', params.q)
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
query.set(key, String(value))
}
})
const suffix = query.toString() ? `?${query}` : ''
return apiRequest<Ticket[]>(`/tickets${suffix}`)
return apiRequest<TicketListResponse>(`/tickets${suffix}`)
}
export function createTicket(payload: TicketInput) {

View File

@@ -8,6 +8,12 @@ export type TicketStatus =
export type TicketPriority = 'low' | 'medium' | 'high' | 'urgent'
export type TicketUser = {
id: number
username: string
display_name: string
}
export type Ticket = {
id: number
ticket_no: string
@@ -18,11 +24,35 @@ export type Ticket = {
impact_scope: string
environment: string
related_system: string
requester_id: number | null
assignee_id: number | null
requester: TicketUser | null
assignee: TicketUser | null
created_at: string
updated_at: string
}
export type TicketListResponse = {
items: Ticket[]
page: number
page_size: number
total: number
total_pages: number
}
export type TicketListView = 'all' | 'assigned_to_me' | 'created_by_me' | 'high_priority'
export type TicketListParams = {
page?: number
page_size?: number
status?: string
priority?: string
q?: string
related_system?: string
assignee_id?: number
view?: TicketListView
}
export type TicketInput = {
title: string
description: string

View File

@@ -48,7 +48,7 @@ export function WorkbenchPage() {
React.useEffect(() => {
listTickets()
.then(setTickets)
.then((response) => setTickets(response.items))
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : '加载失败'))
}, [])