refactor: enhance ticket detail workflow view
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react'
|
||||
import { Bot, CheckCircle2, CircleDot, Send, XCircle } from 'lucide-react'
|
||||
import { Bot, CheckCircle2, CircleDot, Clock3, Send, XCircle } from 'lucide-react'
|
||||
|
||||
import { AppEmptyState } from '@/components/app/AppEmptyState'
|
||||
import { AppDetailLayout } from '@/components/app/AppDetailLayout'
|
||||
@@ -10,8 +10,10 @@ import { AppSection } from '@/components/app/AppSection'
|
||||
import { AppStatusBadge } from '@/components/app/AppStatusBadge'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { SolutionLinkPanel } from '@/features/solutions/SolutionLinkPanel'
|
||||
import { formatDateTime } from '@/lib/format'
|
||||
|
||||
import { getTicket, listTicketEvents, transitionTicket } from './api'
|
||||
import { TicketComments } from './TicketComments'
|
||||
@@ -37,6 +39,14 @@ const STATUS_ACTIONS: Partial<Record<TicketStatus, { label: string; icon: React.
|
||||
closed: { label: '关闭工单', icon: XCircle }
|
||||
}
|
||||
|
||||
const STATUS_ACTION_HINTS: Partial<Record<TicketStatus, string>> = {
|
||||
pending: '补充信息完成后进入支持队列。',
|
||||
in_progress: '确认接手并开始技术排查。',
|
||||
pending_confirm: '已给出处理结果,等待业务侧确认。',
|
||||
resolved: '业务确认问题已解决,保留后续关闭入口。',
|
||||
closed: '关闭代表流程结束,不再继续处理。'
|
||||
}
|
||||
|
||||
function getStatusActionLabel(currentStatus: TicketStatus, targetStatus: TicketStatus) {
|
||||
if (targetStatus === 'closed' && currentStatus !== 'resolved') {
|
||||
return '取消工单'
|
||||
@@ -44,6 +54,16 @@ function getStatusActionLabel(currentStatus: TicketStatus, targetStatus: TicketS
|
||||
return STATUS_ACTIONS[targetStatus]?.label ?? '调整状态'
|
||||
}
|
||||
|
||||
function displayUser(user: Ticket['requester']) {
|
||||
return user?.display_name || user?.username || '未设置'
|
||||
}
|
||||
|
||||
function getActionVariant(status: TicketStatus) {
|
||||
if (status === 'closed') return 'outline'
|
||||
if (status === 'resolved') return 'default'
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
||||
const [ticket, setTicket] = React.useState<Ticket | null>(null)
|
||||
const [events, setEvents] = React.useState<TicketEvent[]>([])
|
||||
@@ -104,34 +124,65 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
||||
|
||||
return (
|
||||
<AppPage maxWidth="6xl">
|
||||
<AppPageHeader
|
||||
title={ticket.title}
|
||||
description={ticket.ticket_no}
|
||||
meta={
|
||||
<>
|
||||
<AppStatusBadge status={ticket.status} />
|
||||
<AppPriorityBadge priority={ticket.priority} />
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{NEXT_STATUSES[ticket.status].map((status) => {
|
||||
const action = STATUS_ACTIONS[status]
|
||||
const Icon = action?.icon
|
||||
return (
|
||||
<Button
|
||||
key={status}
|
||||
variant={status === 'closed' ? 'outline' : 'secondary'}
|
||||
onClick={() => openTransition(status)}
|
||||
>
|
||||
{Icon ? <Icon data-icon="inline-start" /> : null}
|
||||
{getStatusActionLabel(ticket.status, status)}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<AppPageHeader
|
||||
title={ticket.title}
|
||||
description={ticket.ticket_no}
|
||||
meta={
|
||||
<>
|
||||
<AppStatusBadge status={ticket.status} />
|
||||
<AppPriorityBadge priority={ticket.priority} />
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{NEXT_STATUSES[ticket.status].map((status) => {
|
||||
const action = STATUS_ACTIONS[status]
|
||||
const Icon = action?.icon
|
||||
return (
|
||||
<Button
|
||||
key={status}
|
||||
variant={getActionVariant(status)}
|
||||
onClick={() => openTransition(status)}
|
||||
>
|
||||
{Icon ? <Icon data-icon="inline-start" /> : null}
|
||||
{getStatusActionLabel(ticket.status, status)}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<TicketOverviewCard ticket={ticket} />
|
||||
{NEXT_STATUSES[ticket.status].length ? (
|
||||
<Card>
|
||||
<CardContent className="grid gap-3 p-4 md:grid-cols-2">
|
||||
{NEXT_STATUSES[ticket.status].map((status) => {
|
||||
const action = STATUS_ACTIONS[status]
|
||||
const Icon = action?.icon ?? CircleDot
|
||||
return (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
className="flex min-h-20 items-start gap-3 rounded-md border border-border bg-background p-3 text-left transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40"
|
||||
onClick={() => openTransition(status)}
|
||||
>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium text-foreground">
|
||||
{getStatusActionLabel(ticket.status, status)}
|
||||
</span>
|
||||
<span className="mt-1 block text-xs leading-5 text-muted-foreground">
|
||||
{STATUS_ACTION_HINTS[status] ?? '记录本次处理动作并推进流程。'}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
<AppDetailLayout
|
||||
side={
|
||||
<>
|
||||
@@ -167,3 +218,31 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
||||
</AppPage>
|
||||
)
|
||||
}
|
||||
|
||||
function TicketOverviewCard({ ticket }: { ticket: Ticket }) {
|
||||
const items = [
|
||||
{ label: '请求人', value: displayUser(ticket.requester) },
|
||||
{ label: '处理人', value: displayUser(ticket.assignee) },
|
||||
{ label: '关联系统', value: ticket.related_system || '未填写' },
|
||||
{ label: '更新时间', value: formatDateTime(ticket.updated_at) }
|
||||
]
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="grid gap-4 p-4 md:grid-cols-[minmax(0,1fr)_auto] md:items-center">
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="min-w-0">
|
||||
<p className="text-xs font-medium text-muted-foreground">{item.label}</p>
|
||||
<p className="mt-1 truncate text-sm font-medium text-foreground">{item.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Clock3 className="size-4" />
|
||||
创建于 {formatDateTime(ticket.created_at)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user