refactor: enhance ticket detail workflow view

This commit is contained in:
hailin
2026-07-02 16:15:45 +08:00
parent ad270d3809
commit 942b712c20
4 changed files with 165 additions and 64 deletions

View File

@@ -7,8 +7,8 @@ type AppDetailLayoutProps = {
export function AppDetailLayout({ children, side }: AppDetailLayoutProps) { export function AppDetailLayout({ children, side }: AppDetailLayoutProps) {
return ( return (
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_360px]"> <div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="grid min-w-0 gap-4">{children}</div> <div className="grid min-w-0 content-start gap-5">{children}</div>
<aside className="grid content-start gap-4">{side}</aside> <aside className="grid content-start gap-4">{side}</aside>
</div> </div>
) )

View File

@@ -1,5 +1,5 @@
import * as React from 'react' 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 { AppEmptyState } from '@/components/app/AppEmptyState'
import { AppDetailLayout } from '@/components/app/AppDetailLayout' import { AppDetailLayout } from '@/components/app/AppDetailLayout'
@@ -10,8 +10,10 @@ import { AppSection } from '@/components/app/AppSection'
import { AppStatusBadge } from '@/components/app/AppStatusBadge' import { AppStatusBadge } from '@/components/app/AppStatusBadge'
import { Badge } from '@/components/ui/badge' 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 { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { SolutionLinkPanel } from '@/features/solutions/SolutionLinkPanel' import { SolutionLinkPanel } from '@/features/solutions/SolutionLinkPanel'
import { formatDateTime } from '@/lib/format'
import { getTicket, listTicketEvents, transitionTicket } from './api' import { getTicket, listTicketEvents, transitionTicket } from './api'
import { TicketComments } from './TicketComments' import { TicketComments } from './TicketComments'
@@ -37,6 +39,14 @@ const STATUS_ACTIONS: Partial<Record<TicketStatus, { label: string; icon: React.
closed: { label: '关闭工单', icon: XCircle } 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) { function getStatusActionLabel(currentStatus: TicketStatus, targetStatus: TicketStatus) {
if (targetStatus === 'closed' && currentStatus !== 'resolved') { if (targetStatus === 'closed' && currentStatus !== 'resolved') {
return '取消工单' return '取消工单'
@@ -44,6 +54,16 @@ function getStatusActionLabel(currentStatus: TicketStatus, targetStatus: TicketS
return STATUS_ACTIONS[targetStatus]?.label ?? '调整状态' 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 }) { export function TicketDetailPage({ ticketId }: { ticketId: number }) {
const [ticket, setTicket] = React.useState<Ticket | null>(null) const [ticket, setTicket] = React.useState<Ticket | null>(null)
const [events, setEvents] = React.useState<TicketEvent[]>([]) const [events, setEvents] = React.useState<TicketEvent[]>([])
@@ -104,34 +124,65 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
return ( return (
<AppPage maxWidth="6xl"> <AppPage maxWidth="6xl">
<AppPageHeader <AppPageHeader
title={ticket.title} title={ticket.title}
description={ticket.ticket_no} description={ticket.ticket_no}
meta={ meta={
<> <>
<AppStatusBadge status={ticket.status} /> <AppStatusBadge status={ticket.status} />
<AppPriorityBadge priority={ticket.priority} /> <AppPriorityBadge priority={ticket.priority} />
</> </>
} }
actions={ actions={
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{NEXT_STATUSES[ticket.status].map((status) => { {NEXT_STATUSES[ticket.status].map((status) => {
const action = STATUS_ACTIONS[status] const action = STATUS_ACTIONS[status]
const Icon = action?.icon const Icon = action?.icon
return ( return (
<Button <Button
key={status} key={status}
variant={status === 'closed' ? 'outline' : 'secondary'} variant={getActionVariant(status)}
onClick={() => openTransition(status)} onClick={() => openTransition(status)}
> >
{Icon ? <Icon data-icon="inline-start" /> : null} {Icon ? <Icon data-icon="inline-start" /> : null}
{getStatusActionLabel(ticket.status, status)} {getStatusActionLabel(ticket.status, status)}
</Button> </Button>
) )
})} })}
</div> </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 <AppDetailLayout
side={ side={
<> <>
@@ -167,3 +218,31 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
</AppPage> </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>
)
}

View File

@@ -1,6 +1,10 @@
import { CheckCircle2, CircleDot, MessageSquareText, Paperclip, PencilLine } from 'lucide-react'
import { AppSection } from '@/components/app/AppSection' import { AppSection } from '@/components/app/AppSection'
import { AppStatusBadge } from '@/components/app/AppStatusBadge' import { AppStatusBadge } from '@/components/app/AppStatusBadge'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { formatDateTime } from '@/lib/format'
import type { Ticket, TicketEvent } from './types' import type { Ticket, TicketEvent } from './types'
@@ -27,13 +31,12 @@ const COMMENT_TYPE_LABELS: Record<string, string> = {
user: '用户评论' user: '用户评论'
} }
function formatEventTime(value: string) { const EVENT_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
return new Intl.DateTimeFormat('zh-CN', { created: CheckCircle2,
month: 'numeric', updated: PencilLine,
day: 'numeric', status_change: CircleDot,
hour: '2-digit', commented: MessageSquareText,
minute: '2-digit' attachment_added: Paperclip
}).format(new Date(value))
} }
function displayStatus(value: unknown) { function displayStatus(value: unknown) {
@@ -65,6 +68,13 @@ function describeEvent(event: TicketEvent) {
return event.remark || '记录已更新' return event.remark || '记录已更新'
} }
function eventVariant(eventType: string) {
if (eventType === 'status_change') return 'info'
if (eventType === 'commented') return 'secondary'
if (eventType === 'created') return 'success'
return 'outline'
}
type TicketStatusTimelineProps = { type TicketStatusTimelineProps = {
ticket: Ticket ticket: Ticket
events: TicketEvent[] events: TicketEvent[]
@@ -73,15 +83,16 @@ type TicketStatusTimelineProps = {
export function TicketStatusTimeline({ ticket, events }: TicketStatusTimelineProps) { export function TicketStatusTimeline({ ticket, events }: TicketStatusTimelineProps) {
return ( return (
<AppSection title="处理记录"> <AppSection title="处理记录">
<div className="grid gap-3"> <div className="grid gap-4">
<div className="flex items-center justify-between gap-3 rounded-md border border-border bg-background px-3 py-2"> <div className="grid gap-3 rounded-md border border-border bg-background p-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium text-foreground">当前状态</p> <div className="flex flex-wrap items-center gap-2">
<p className="mt-1 text-xs text-muted-foreground"> <p className="text-sm font-medium text-foreground">当前流程状态</p>
最后更新时间:{new Date(ticket.updated_at).toLocaleString('zh-CN')} <AppStatusBadge status={ticket.status} />
</p> </div>
<p className="mt-1 text-xs text-muted-foreground">最后更新时间:{formatDateTime(ticket.updated_at)}</p>
</div> </div>
<AppStatusBadge status={ticket.status} /> <Badge variant="outline">{ticket.ticket_no}</Badge>
</div> </div>
{events.length === 0 ? ( {events.length === 0 ? (
@@ -89,25 +100,28 @@ export function TicketStatusTimeline({ ticket, events }: TicketStatusTimelinePro
<p className="text-sm text-muted-foreground">暂无处理记录</p> <p className="text-sm text-muted-foreground">暂无处理记录</p>
</div> </div>
) : ( ) : (
<div className="grid gap-2"> <div className="grid gap-0">
{events.map((event, index) => ( {events.map((event, index) => (
<div key={event.id} className="grid grid-cols-[auto_minmax(0,1fr)] gap-3"> <div key={event.id} className="grid grid-cols-[auto_minmax(0,1fr)] gap-3">
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div className="size-2 rounded-full bg-primary" /> <TimelineIcon eventType={event.event_type} />
{index < events.length - 1 ? <div className="h-full min-h-8 w-px bg-border" /> : null} {index < events.length - 1 ? <div className="h-full min-h-10 w-px bg-border" /> : null}
</div> </div>
<div className="pb-3"> <div className="pb-4">
<div className="flex flex-wrap items-center gap-2"> <div className="rounded-md border border-border bg-background p-3">
<p className="text-sm font-medium text-foreground"> <div className="flex flex-wrap items-center justify-between gap-2">
{EVENT_LABELS[event.event_type] ?? event.event_type} <div className="flex min-w-0 flex-wrap items-center gap-2">
</p> <p className="text-sm font-medium text-foreground">
{event.event_type === 'created' ? <Badge variant="secondary">{ticket.ticket_no}</Badge> : null} {EVENT_LABELS[event.event_type] ?? event.event_type}
</p>
<Badge variant={eventVariant(event.event_type)}>{displayOperator(event)}</Badge>
</div>
<p className="text-xs text-muted-foreground">{formatDateTime(event.created_at)}</p>
</div>
<Separator className="my-2" />
<p className="text-sm text-foreground">{describeEvent(event)}</p>
{event.remark ? <p className="mt-2 text-xs leading-5 text-muted-foreground">说明:{event.remark}</p> : null}
</div> </div>
<p className="mt-1 text-sm text-foreground">{describeEvent(event)}</p>
{event.remark ? <p className="mt-1 text-xs text-muted-foreground">说明:{event.remark}</p> : null}
<p className="mt-1 text-xs text-muted-foreground">
{displayOperator(event)} · {formatEventTime(event.created_at)}
</p>
</div> </div>
</div> </div>
))} ))}
@@ -117,3 +131,13 @@ export function TicketStatusTimeline({ ticket, events }: TicketStatusTimelinePro
</AppSection> </AppSection>
) )
} }
function TimelineIcon({ eventType }: { eventType: string }) {
const Icon = EVENT_ICONS[eventType] ?? CircleDot
return (
<div className="flex size-7 items-center justify-center rounded-full border border-border bg-card text-primary shadow-sm">
<Icon className="size-4" />
</div>
)
}

View File

@@ -1,5 +1,6 @@
import * as React from 'react' import * as React from 'react'
import { AppField } from '@/components/app/AppField'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
Dialog, Dialog,
@@ -66,10 +67,7 @@ export function TicketTransitionDialog({
<DialogTitle>{actionLabel}</DialogTitle> <DialogTitle>{actionLabel}</DialogTitle>
<DialogDescription>请说明本次流转的原因、处理动作或待确认事项。</DialogDescription> <DialogDescription>请说明本次流转的原因、处理动作或待确认事项。</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-2"> <AppField label="处理说明" htmlFor="transition-remark" required>
<label className="text-sm font-medium text-foreground" htmlFor="transition-remark">
处理说明
</label>
<Textarea <Textarea
id="transition-remark" id="transition-remark"
required required
@@ -78,7 +76,7 @@ export function TicketTransitionDialog({
value={remark} value={remark}
onChange={(event) => setRemark(event.target.value)} onChange={(event) => setRemark(event.target.value)}
/> />
</div> </AppField>
<DialogFooter> <DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}> <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
取消 取消