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) {
return (
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="grid min-w-0 gap-4">{children}</div>
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_360px]">
<div className="grid min-w-0 content-start gap-5">{children}</div>
<aside className="grid content-start gap-4">{side}</aside>
</div>
)

View File

@@ -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[]>([])
@@ -121,7 +141,7 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
return (
<Button
key={status}
variant={status === 'closed' ? 'outline' : 'secondary'}
variant={getActionVariant(status)}
onClick={() => openTransition(status)}
>
{Icon ? <Icon data-icon="inline-start" /> : null}
@@ -132,6 +152,37 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
</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>
)
}

View File

@@ -1,6 +1,10 @@
import { CheckCircle2, CircleDot, MessageSquareText, Paperclip, PencilLine } from 'lucide-react'
import { AppSection } from '@/components/app/AppSection'
import { AppStatusBadge } from '@/components/app/AppStatusBadge'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { formatDateTime } from '@/lib/format'
import type { Ticket, TicketEvent } from './types'
@@ -27,13 +31,12 @@ const COMMENT_TYPE_LABELS: Record<string, string> = {
user: '用户评论'
}
function formatEventTime(value: string) {
return new Intl.DateTimeFormat('zh-CN', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).format(new Date(value))
const EVENT_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
created: CheckCircle2,
updated: PencilLine,
status_change: CircleDot,
commented: MessageSquareText,
attachment_added: Paperclip
}
function displayStatus(value: unknown) {
@@ -65,6 +68,13 @@ function describeEvent(event: TicketEvent) {
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 = {
ticket: Ticket
events: TicketEvent[]
@@ -73,41 +83,45 @@ type TicketStatusTimelineProps = {
export function TicketStatusTimeline({ ticket, events }: TicketStatusTimelineProps) {
return (
<AppSection title="处理记录">
<div className="grid gap-3">
<div className="flex items-center justify-between gap-3 rounded-md border border-border bg-background px-3 py-2">
<div className="grid gap-4">
<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">
<p className="text-sm font-medium text-foreground">当前状态</p>
<p className="mt-1 text-xs text-muted-foreground">
最后更新时间:{new Date(ticket.updated_at).toLocaleString('zh-CN')}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-medium text-foreground">当前流程状态</p>
<AppStatusBadge status={ticket.status} />
</div>
<p className="mt-1 text-xs text-muted-foreground">最后更新时间:{formatDateTime(ticket.updated_at)}</p>
</div>
<Badge variant="outline">{ticket.ticket_no}</Badge>
</div>
{events.length === 0 ? (
<div className="rounded-md border border-dashed border-border p-3">
<p className="text-sm text-muted-foreground">暂无处理记录</p>
</div>
) : (
<div className="grid gap-2">
<div className="grid gap-0">
{events.map((event, index) => (
<div key={event.id} className="grid grid-cols-[auto_minmax(0,1fr)] gap-3">
<div className="flex flex-col items-center">
<div className="size-2 rounded-full bg-primary" />
{index < events.length - 1 ? <div className="h-full min-h-8 w-px bg-border" /> : null}
<TimelineIcon eventType={event.event_type} />
{index < events.length - 1 ? <div className="h-full min-h-10 w-px bg-border" /> : null}
</div>
<div className="pb-3">
<div className="flex flex-wrap items-center gap-2">
<div className="pb-4">
<div className="rounded-md border border-border bg-background p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<p className="text-sm font-medium text-foreground">
{EVENT_LABELS[event.event_type] ?? event.event_type}
</p>
{event.event_type === 'created' ? <Badge variant="secondary">{ticket.ticket_no}</Badge> : null}
<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>
<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>
))}
@@ -117,3 +131,13 @@ export function TicketStatusTimeline({ ticket, events }: TicketStatusTimelinePro
</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 { AppField } from '@/components/app/AppField'
import { Button } from '@/components/ui/button'
import {
Dialog,
@@ -66,10 +67,7 @@ export function TicketTransitionDialog({
<DialogTitle>{actionLabel}</DialogTitle>
<DialogDescription>请说明本次流转的原因、处理动作或待确认事项。</DialogDescription>
</DialogHeader>
<div className="grid gap-2">
<label className="text-sm font-medium text-foreground" htmlFor="transition-remark">
处理说明
</label>
<AppField label="处理说明" htmlFor="transition-remark" required>
<Textarea
id="transition-remark"
required
@@ -78,7 +76,7 @@ export function TicketTransitionDialog({
value={remark}
onChange={(event) => setRemark(event.target.value)}
/>
</div>
</AppField>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
取消