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 * as React from 'react'
|
||||||
|
import { ArrowRight, 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'
|
||||||
import { AppFilterBar } from '@/components/app/AppFilterBar'
|
|
||||||
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 { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import {
|
||||||
import { Input } from '@/components/ui/input'
|
Pagination,
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
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 { formatDateTime } from '@/lib/format'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
import { listTickets } from './api'
|
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>[] = [
|
const columns: AppDataTableColumn<Ticket>[] = [
|
||||||
{
|
{
|
||||||
@@ -24,7 +43,14 @@ const columns: AppDataTableColumn<Ticket>[] = [
|
|||||||
{
|
{
|
||||||
key: 'title',
|
key: 'title',
|
||||||
header: '标题',
|
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',
|
key: 'status',
|
||||||
@@ -46,6 +72,11 @@ const columns: AppDataTableColumn<Ticket>[] = [
|
|||||||
header: '影响范围',
|
header: '影响范围',
|
||||||
cell: (ticket) => <span className="text-muted-foreground">{ticket.impact_scope || '-'}</span>
|
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',
|
key: 'created_at',
|
||||||
header: '创建时间',
|
header: '创建时间',
|
||||||
@@ -57,102 +88,182 @@ const columns: AppDataTableColumn<Ticket>[] = [
|
|||||||
cell: (ticket) => (
|
cell: (ticket) => (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
className="gap-1"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
window.location.hash = `/tickets/${ticket.id}`
|
window.location.hash = `/tickets/${ticket.id}`
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
查看详情
|
查看详情
|
||||||
|
<ArrowRight className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
export function TicketListPage() {
|
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 [status, setStatus] = React.useState('all')
|
||||||
const [priority, setPriority] = React.useState('all')
|
const [priority, setPriority] = React.useState('all')
|
||||||
|
const [relatedSystem, setRelatedSystem] = React.useState('')
|
||||||
const [q, setQ] = React.useState('')
|
const [q, setQ] = React.useState('')
|
||||||
const [error, setError] = React.useState('')
|
const [error, setError] = React.useState('')
|
||||||
|
const [loading, setLoading] = React.useState(true)
|
||||||
|
const [page, setPage] = React.useState(1)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
listTickets({ status: status === 'all' ? '' : status, q })
|
setLoading(true)
|
||||||
.then(setTickets)
|
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 : '加载失败'))
|
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : '加载失败'))
|
||||||
}, [status, q])
|
.finally(() => setLoading(false))
|
||||||
|
}, [page, priority, q, relatedSystem, status, view])
|
||||||
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 }
|
|
||||||
]
|
|
||||||
|
|
||||||
function resetFilters() {
|
function resetFilters() {
|
||||||
setStatus('all')
|
setStatus('all')
|
||||||
setPriority('all')
|
setPriority('all')
|
||||||
|
setRelatedSystem('')
|
||||||
setQ('')
|
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 (
|
return (
|
||||||
<main className="min-h-screen bg-background p-6 text-foreground">
|
<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
|
<AppPageHeader
|
||||||
title="工单中心"
|
title="工单中心"
|
||||||
description="统一查看、筛选和处理技术支持工单。"
|
description="集中查看、筛选和处理企业技术支持工单。"
|
||||||
actions={<Button onClick={() => (window.location.hash = '/tickets/new')}>新建工单</Button>}
|
actions={
|
||||||
|
<Button onClick={() => (window.location.hash = '/tickets/new')}>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
新建工单
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-4">
|
<TicketMetricCards tickets={result.items} />
|
||||||
{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>
|
|
||||||
|
|
||||||
<AppFilterBar actions={<Button variant="outline" onClick={resetFilters}>重置筛选</Button>}>
|
<Tabs value={view} onValueChange={updateView}>
|
||||||
<Input onChange={(event) => setQ(event.target.value)} placeholder="搜索工单编号或标题" value={q} />
|
<TabsList>
|
||||||
<Select onValueChange={setStatus} value={status}>
|
{viewTabs.map((tab) => (
|
||||||
<SelectTrigger className="w-full">
|
<TabsTrigger key={tab.value} value={tab.value}>
|
||||||
<SelectValue placeholder="全部状态" />
|
{tab.label}
|
||||||
</SelectTrigger>
|
</TabsTrigger>
|
||||||
<SelectContent>
|
))}
|
||||||
<SelectItem value="all">全部状态</SelectItem>
|
</TabsList>
|
||||||
<SelectItem value="pending">待处理</SelectItem>
|
</Tabs>
|
||||||
<SelectItem value="in_progress">处理中</SelectItem>
|
|
||||||
<SelectItem value="pending_confirm">待确认</SelectItem>
|
<TicketFilterBar
|
||||||
<SelectItem value="resolved">已解决</SelectItem>
|
q={q}
|
||||||
<SelectItem value="closed">已关闭</SelectItem>
|
status={status}
|
||||||
</SelectContent>
|
priority={priority}
|
||||||
</Select>
|
relatedSystem={relatedSystem}
|
||||||
<Select onValueChange={setPriority} value={priority}>
|
onQChange={updateQuery}
|
||||||
<SelectTrigger className="w-full">
|
onStatusChange={updateStatus}
|
||||||
<SelectValue placeholder="全部优先级" />
|
onPriorityChange={updatePriority}
|
||||||
</SelectTrigger>
|
onRelatedSystemChange={updateRelatedSystem}
|
||||||
<SelectContent>
|
onReset={resetFilters}
|
||||||
<SelectItem value="all">全部优先级</SelectItem>
|
/>
|
||||||
<SelectItem value="low">低</SelectItem>
|
|
||||||
<SelectItem value="medium">中</SelectItem>
|
|
||||||
<SelectItem value="high">高</SelectItem>
|
|
||||||
<SelectItem value="urgent">紧急</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</AppFilterBar>
|
|
||||||
|
|
||||||
{error ? <AppEmptyState title="无法加载工单" description={error} /> : null}
|
{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
|
<AppDataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rows={visibleTickets}
|
rows={result.items}
|
||||||
getRowKey={(ticket) => ticket.id}
|
getRowKey={(ticket) => ticket.id}
|
||||||
emptyTitle="暂无工单"
|
emptyTitle="暂无工单"
|
||||||
emptyDescription="创建第一张工单后,列表会显示在这里。"
|
emptyDescription="调整筛选条件,或创建第一张工单后再查看。"
|
||||||
footer={<p className="text-sm text-muted-foreground">当前显示 {visibleTickets.length} 条工单</p>}
|
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}`)}
|
onRowClick={(ticket) => (window.location.hash = `/tickets/${ticket.id}`)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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 { 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()
|
const query = new URLSearchParams()
|
||||||
if (params.status) query.set('status', params.status)
|
Object.entries(params).forEach(([key, value]) => {
|
||||||
if (params.q) query.set('q', params.q)
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
|
query.set(key, String(value))
|
||||||
|
}
|
||||||
|
})
|
||||||
const suffix = query.toString() ? `?${query}` : ''
|
const suffix = query.toString() ? `?${query}` : ''
|
||||||
return apiRequest<Ticket[]>(`/tickets${suffix}`)
|
return apiRequest<TicketListResponse>(`/tickets${suffix}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTicket(payload: TicketInput) {
|
export function createTicket(payload: TicketInput) {
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ export type TicketStatus =
|
|||||||
|
|
||||||
export type TicketPriority = 'low' | 'medium' | 'high' | 'urgent'
|
export type TicketPriority = 'low' | 'medium' | 'high' | 'urgent'
|
||||||
|
|
||||||
|
export type TicketUser = {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
display_name: string
|
||||||
|
}
|
||||||
|
|
||||||
export type Ticket = {
|
export type Ticket = {
|
||||||
id: number
|
id: number
|
||||||
ticket_no: string
|
ticket_no: string
|
||||||
@@ -18,11 +24,35 @@ export type Ticket = {
|
|||||||
impact_scope: string
|
impact_scope: string
|
||||||
environment: string
|
environment: string
|
||||||
related_system: string
|
related_system: string
|
||||||
|
requester_id: number | null
|
||||||
assignee_id: number | null
|
assignee_id: number | null
|
||||||
|
requester: TicketUser | null
|
||||||
|
assignee: TicketUser | null
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_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 = {
|
export type TicketInput = {
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function WorkbenchPage() {
|
|||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
listTickets()
|
listTickets()
|
||||||
.then(setTickets)
|
.then((response) => setTickets(response.items))
|
||||||
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : '加载失败'))
|
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : '加载失败'))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user