refactor: refine ticket center operations view

This commit is contained in:
hailin
2026-07-02 16:18:04 +08:00
parent 942b712c20
commit f8af731bc3

View File

@@ -1,5 +1,5 @@
import * as React from 'react' import * as React from 'react'
import { ArrowRight, Plus } from 'lucide-react' import { ArrowRight, Clock3, Plus } from 'lucide-react'
import { AppDataTable, type AppDataTableColumn } from '@/components/app/AppDataTable' import { AppDataTable, type AppDataTableColumn } from '@/components/app/AppDataTable'
import { AppEmptyState } from '@/components/app/AppEmptyState' import { AppEmptyState } from '@/components/app/AppEmptyState'
@@ -7,7 +7,9 @@ import { AppPage } from '@/components/app/AppPage'
import { AppPageHeader } from '@/components/app/AppPageHeader' import { AppPageHeader } from '@/components/app/AppPageHeader'
import { AppPriorityBadge } from '@/components/app/AppPriorityBadge' import { AppPriorityBadge } from '@/components/app/AppPriorityBadge'
import { AppStatusBadge } from '@/components/app/AppStatusBadge' import { AppStatusBadge } from '@/components/app/AppStatusBadge'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { import {
Pagination, Pagination,
PaginationContent, PaginationContent,
@@ -18,6 +20,7 @@ import {
} from '@/components/ui/pagination' } from '@/components/ui/pagination'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { formatDateTime } from '@/lib/format'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { listTickets } from './api' import { listTickets } from './api'
@@ -43,61 +46,77 @@ const viewTabs: Array<{ value: TicketListView; label: string }> = [
{ value: 'high_priority', label: '高优先级' } { value: 'high_priority', label: '高优先级' }
] ]
function displayUser(user: Ticket['assignee']) {
return user?.display_name || user?.username || '未分派'
}
function getTicketAgeLabel(ticket: Ticket) {
const createdAt = new Date(ticket.created_at).getTime()
const diffHours = Math.max(0, Math.floor((Date.now() - createdAt) / 1000 / 60 / 60))
if (diffHours < 1) return '1 小时内'
if (diffHours < 24) return `${diffHours} 小时`
return `${Math.floor(diffHours / 24)} 天`
}
const columns: AppDataTableColumn<Ticket>[] = [ const columns: AppDataTableColumn<Ticket>[] = [
{ {
key: 'ticket_no', key: 'ticket',
header: '编号', header: '工单',
className: 'w-[14%]', className: 'w-[34%] whitespace-normal',
cell: (ticket) => <span className="font-medium text-foreground">{ticket.ticket_no}</span>
},
{
key: 'title',
header: '标题',
className: 'w-[24%] whitespace-normal',
cell: (ticket) => ( cell: (ticket) => (
<div className="min-w-0"> <div className="min-w-0">
<p className="line-clamp-1 font-medium text-foreground">{ticket.title}</p> <div className="flex flex-wrap items-center gap-2">
<p className="mt-1 line-clamp-1 text-xs text-muted-foreground"> <Badge variant="outline">{ticket.ticket_no}</Badge>
{ticket.requester?.display_name ? `请求人:${ticket.requester.display_name}` : ticket.environment || '暂无环境信息'} <span className="text-xs text-muted-foreground">请求人:{displayUser(ticket.requester)}</span>
</p> </div>
<p className="mt-2 line-clamp-1 font-medium text-foreground">{ticket.title}</p>
<p className="mt-1 line-clamp-1 text-xs text-muted-foreground">{ticket.description || '暂无问题描述'}</p>
</div> </div>
) )
}, },
{ {
key: 'status', key: 'workflow',
header: '状态', header: '流程',
className: 'w-[8%]', className: 'w-[16%]',
cell: (ticket) => <AppStatusBadge status={ticket.status} /> cell: (ticket) => (
<div className="flex flex-wrap gap-1.5">
<AppStatusBadge status={ticket.status} />
<AppPriorityBadge priority={ticket.priority} />
</div>
)
}, },
{ {
key: 'priority', key: 'context',
header: '优先级', header: '业务上下文',
className: 'w-[7%]', className: 'w-[22%] whitespace-normal',
cell: (ticket) => <AppPriorityBadge priority={ticket.priority} /> cell: (ticket) => (
}, <div className="grid min-w-0 gap-1">
{ <p className="truncate text-sm text-foreground">{ticket.related_system || '未填写系统'}</p>
key: 'related_system', <p className="line-clamp-1 text-xs text-muted-foreground">{ticket.impact_scope || ticket.environment || '暂无影响范围'}</p>
header: '关联系统', </div>
className: 'w-[10%]', )
cell: (ticket) => <span className="block truncate text-muted-foreground">{ticket.related_system || '-'}</span>
},
{
key: 'impact_scope',
header: '影响范围',
className: 'w-[11%]',
cell: (ticket) => <span className="block truncate text-muted-foreground">{ticket.impact_scope || '-'}</span>
}, },
{ {
key: 'assignee', key: 'assignee',
header: '处理人', header: '责任人',
className: 'w-[8%]', className: 'w-[12%]',
cell: (ticket) => <span className="block truncate text-muted-foreground">{ticket.assignee?.display_name || '未分派'}</span> cell: (ticket) => (
<div className="grid gap-1">
<span className="truncate text-sm text-foreground">{displayUser(ticket.assignee)}</span>
<span className="text-xs text-muted-foreground">{ticket.assignee ? '已分派' : '待分派'}</span>
</div>
)
}, },
{ {
key: 'created_at', key: 'updated_at',
header: '创建时间', header: '时间',
className: 'w-[10%]', className: 'w-[10%]',
cell: (ticket) => <span className="text-muted-foreground">{formatTicketTableDate(ticket.created_at)}</span> cell: (ticket) => (
<div className="grid gap-1 text-muted-foreground">
<span className="text-sm">{formatTicketTableDate(ticket.updated_at)}</span>
<span className="text-xs">已创建 {getTicketAgeLabel(ticket)}</span>
</div>
)
}, },
{ {
key: 'actions', key: 'actions',
@@ -194,97 +213,111 @@ export function TicketListPage() {
setPage(target) setPage(target)
} }
const hasActiveFilters = status !== 'all' || priority !== 'all' || relatedSystem.trim() || q.trim()
return ( return (
<AppPage contentClassName="gap-5"> <AppPage contentClassName="gap-5">
<AppPageHeader <AppPageHeader
title="工单中心" title="工单中心"
actions={ description="集中查看、筛选和推进企业技术支持请求。"
<Button onClick={() => (window.location.hash = '/tickets/new')}> meta={hasActiveFilters ? <Badge variant="info">已筛选</Badge> : <Badge variant="outline">全部范围</Badge>}
<Plus data-icon="inline-start" /> actions={
新建工单 <Button onClick={() => (window.location.hash = '/tickets/new')}>
</Button> <Plus data-icon="inline-start" />
} 新建工单
/> </Button>
}
/>
<TicketMetricCards tickets={result.items} /> <TicketMetricCards tickets={result.items} />
<Tabs value={view} onValueChange={updateView}> <Card className="py-0">
<TabsList> <CardContent className="flex flex-col gap-3 p-3 lg:flex-row lg:items-center lg:justify-between">
{viewTabs.map((tab) => ( <Tabs value={view} onValueChange={updateView}>
<TabsTrigger key={tab.value} value={tab.value}> <TabsList>
{tab.label} {viewTabs.map((tab) => (
</TabsTrigger> <TabsTrigger key={tab.value} value={tab.value}>
))} {tab.label}
</TabsList> </TabsTrigger>
</Tabs> ))}
</TabsList>
</Tabs>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Clock3 className="size-4" />
最近更新:{result.items[0]?.updated_at ? formatDateTime(result.items[0].updated_at) : '暂无数据'}
</div>
</CardContent>
</Card>
<TicketFilterBar <TicketFilterBar
q={q} q={q}
status={status} status={status}
priority={priority} priority={priority}
relatedSystem={relatedSystem} relatedSystem={relatedSystem}
onQChange={updateQuery} onQChange={updateQuery}
onStatusChange={updateStatus} onStatusChange={updateStatus}
onPriorityChange={updatePriority} onPriorityChange={updatePriority}
onRelatedSystemChange={updateRelatedSystem} onRelatedSystemChange={updateRelatedSystem}
onReset={resetFilters} onReset={resetFilters}
/> />
{error ? <AppEmptyState title="无法加载工单" description={error} /> : null} {error ? <AppEmptyState title="无法加载工单" description={error} /> : null}
{loading && !error ? ( {loading && !error ? (
<div className="grid gap-3 rounded-lg border border-border bg-surface p-4"> <Card>
<CardContent className="grid gap-3 p-4">
<Skeleton className="h-8 w-48" /> <Skeleton className="h-8 w-48" />
<Skeleton className="h-32 w-full" /> <Skeleton className="h-32 w-full" />
<Skeleton className="h-8 w-64" /> <Skeleton className="h-8 w-64" />
</div> </CardContent>
) : null} </Card>
{!error && !loading ? ( ) : null}
<AppDataTable {!error && !loading ? (
columns={columns} <AppDataTable
rows={result.items} columns={columns}
getRowKey={(ticket) => ticket.id} rows={result.items}
emptyTitle="暂无工单" getRowKey={(ticket) => ticket.id}
emptyDescription="调整筛选条件,或创建第一张工单后再查看。" emptyTitle="暂无工单"
footer={ emptyDescription="调整筛选条件,或创建第一张工单后再查看。"
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> footer={
<p className="text-sm text-muted-foreground"> <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
共 {result.total} 条,当前第 {result.page} / {Math.max(result.total_pages, 1)} 页 <p className="text-sm text-muted-foreground">
</p> 共 {result.total} 条,当前第 {result.page} / {Math.max(result.total_pages, 1)} 页
<Pagination className="mx-0 w-auto justify-start md:justify-end"> </p>
<PaginationContent> <Pagination className="mx-0 w-auto justify-start md:justify-end">
<PaginationItem> <PaginationContent>
<PaginationPrevious <PaginationItem>
href="#" <PaginationPrevious
className={cn(result.page <= 1 && 'pointer-events-none opacity-50')} href="#"
onClick={(event) => { className={cn(result.page <= 1 && 'pointer-events-none opacity-50')}
event.preventDefault() onClick={(event) => {
goToPage(result.page - 1) event.preventDefault()
}} goToPage(result.page - 1)
/> }}
</PaginationItem> />
<PaginationItem> </PaginationItem>
<PaginationLink href="#" isActive onClick={(event) => event.preventDefault()}> <PaginationItem>
{result.page} <PaginationLink href="#" isActive onClick={(event) => event.preventDefault()}>
</PaginationLink> {result.page}
</PaginationItem> </PaginationLink>
<PaginationItem> </PaginationItem>
<PaginationNext <PaginationItem>
href="#" <PaginationNext
className={cn(result.page >= result.total_pages && 'pointer-events-none opacity-50')} href="#"
onClick={(event) => { className={cn(result.page >= result.total_pages && 'pointer-events-none opacity-50')}
event.preventDefault() onClick={(event) => {
goToPage(result.page + 1) event.preventDefault()
}} goToPage(result.page + 1)
/> }}
</PaginationItem> />
</PaginationContent> </PaginationItem>
</Pagination> </PaginationContent>
</div> </Pagination>
} </div>
tableClassName="table-fixed" }
onRowClick={(ticket) => (window.location.hash = `/tickets/${ticket.id}`)} tableClassName="table-fixed min-w-[980px]"
/> onRowClick={(ticket) => (window.location.hash = `/tickets/${ticket.id}`)}
) : null} />
) : null}
</AppPage> </AppPage>
) )
} }