feat: redesign ticket list experience
This commit is contained in:
74
frontend/src/features/tickets/TicketFilterBar.tsx
Normal file
74
frontend/src/features/tickets/TicketFilterBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
63
frontend/src/features/tickets/TicketMetricCards.tsx
Normal file
63
frontend/src/features/tickets/TicketMetricCards.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 : '加载失败'))
|
||||
}, [])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user