From 382237e9f5ec80b44791a5350fdf75ce60ae63d2 Mon Sep 17 00:00:00 2001 From: hailin Date: Wed, 1 Jul 2026 15:16:05 +0800 Subject: [PATCH] feat: redesign ticket list experience --- .../src/features/tickets/TicketFilterBar.tsx | 74 ++++++ .../src/features/tickets/TicketListPage.tsx | 237 +++++++++++++----- .../features/tickets/TicketMetricCards.tsx | 63 +++++ frontend/src/features/tickets/api.ts | 13 +- frontend/src/features/tickets/types.ts | 30 +++ .../src/features/workbench/WorkbenchPage.tsx | 2 +- 6 files changed, 350 insertions(+), 69 deletions(-) create mode 100644 frontend/src/features/tickets/TicketFilterBar.tsx create mode 100644 frontend/src/features/tickets/TicketMetricCards.tsx diff --git a/frontend/src/features/tickets/TicketFilterBar.tsx b/frontend/src/features/tickets/TicketFilterBar.tsx new file mode 100644 index 0000000..34ed7f3 --- /dev/null +++ b/frontend/src/features/tickets/TicketFilterBar.tsx @@ -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 ( + + + 重置筛选 + + } + > + onQChange(event.target.value)} placeholder="搜索编号、标题或描述" value={q} /> + + + onRelatedSystemChange(event.target.value)} + placeholder="按关联系统筛选" + value={relatedSystem} + /> + + ) +} diff --git a/frontend/src/features/tickets/TicketListPage.tsx b/frontend/src/features/tickets/TicketListPage.tsx index 8850053..faefaea 100644 --- a/frontend/src/features/tickets/TicketListPage.tsx +++ b/frontend/src/features/tickets/TicketListPage.tsx @@ -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[] = [ { @@ -24,7 +43,14 @@ const columns: AppDataTableColumn[] = [ { key: 'title', header: '标题', - cell: (ticket) => {ticket.title} + cell: (ticket) => ( +
+

{ticket.title}

+

+ {ticket.requester?.display_name ? `请求人:${ticket.requester.display_name}` : ticket.environment || '暂无环境信息'} +

+
+ ) }, { key: 'status', @@ -46,6 +72,11 @@ const columns: AppDataTableColumn[] = [ header: '影响范围', cell: (ticket) => {ticket.impact_scope || '-'} }, + { + key: 'assignee', + header: '处理人', + cell: (ticket) => {ticket.assignee?.display_name || '未分派'} + }, { key: 'created_at', header: '创建时间', @@ -57,102 +88,182 @@ const columns: AppDataTableColumn[] = [ cell: (ticket) => ( ) } ] export function TicketListPage() { - const [tickets, setTickets] = React.useState([]) + const [result, setResult] = React.useState({ + items: [], + page: 1, + page_size: PAGE_SIZE, + total: 0, + total_pages: 0 + }) + const [view, setView] = React.useState('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 (
-
+
(window.location.hash = '/tickets/new')}>新建工单} + description="集中查看、筛选和处理企业技术支持工单。" + actions={ + + } /> -
- {metrics.map((metric) => ( - - -

{metric.label}

-

{metric.value}

-
-
- ))} -
+ - 重置筛选}> - setQ(event.target.value)} placeholder="搜索工单编号或标题" value={q} /> - - - + + + {viewTabs.map((tab) => ( + + {tab.label} + + ))} + + + + {error ? : null} - {!error ? ( + {loading && !error ? ( +
+ + + +
+ ) : null} + {!error && !loading ? ( ticket.id} emptyTitle="暂无工单" - emptyDescription="创建第一张工单后,列表会显示在这里。" - footer={

当前显示 {visibleTickets.length} 条工单

} + emptyDescription="调整筛选条件,或创建第一张工单后再查看。" + footer={ +
+

+ 共 {result.total} 条,当前第 {result.page} / {Math.max(result.total_pages, 1)} 页 +

+ + + + { + event.preventDefault() + goToPage(result.page - 1) + }} + /> + + + event.preventDefault()}> + {result.page} + + + + = result.total_pages && 'pointer-events-none opacity-50')} + onClick={(event) => { + event.preventDefault() + goToPage(result.page + 1) + }} + /> + + + +
+ } onRowClick={(ticket) => (window.location.hash = `/tickets/${ticket.id}`)} /> ) : null} diff --git a/frontend/src/features/tickets/TicketMetricCards.tsx b/frontend/src/features/tickets/TicketMetricCards.tsx new file mode 100644 index 0000000..5a6f1a4 --- /dev/null +++ b/frontend/src/features/tickets/TicketMetricCards.tsx @@ -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 ( +
+ {metricConfig.map((metric) => { + const Icon = metric.icon + return ( + + +
+ {metric.label} +

{metric.description}

+
+
+ +
+
+ +

{metric.value(tickets)}

+
+
+ ) + })} +
+ ) +} diff --git a/frontend/src/features/tickets/api.ts b/frontend/src/features/tickets/api.ts index faf1388..d4bd744 100644 --- a/frontend/src/features/tickets/api.ts +++ b/frontend/src/features/tickets/api.ts @@ -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(`/tickets${suffix}`) + return apiRequest(`/tickets${suffix}`) } export function createTicket(payload: TicketInput) { diff --git a/frontend/src/features/tickets/types.ts b/frontend/src/features/tickets/types.ts index dfb6281..d8fe4e8 100644 --- a/frontend/src/features/tickets/types.ts +++ b/frontend/src/features/tickets/types.ts @@ -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 diff --git a/frontend/src/features/workbench/WorkbenchPage.tsx b/frontend/src/features/workbench/WorkbenchPage.tsx index 149f6ef..062e9ea 100644 --- a/frontend/src/features/workbench/WorkbenchPage.tsx +++ b/frontend/src/features/workbench/WorkbenchPage.tsx @@ -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 : '加载失败')) }, [])