feat: redesign ticket detail experience
This commit is contained in:
@@ -1,34 +1,47 @@
|
||||
import * as React from 'react'
|
||||
import { Bot, CheckCircle2, CircleDot, Send, XCircle } from 'lucide-react'
|
||||
|
||||
import { AppEmptyState } from '@/components/app/AppEmptyState'
|
||||
import { AppDetailLayout } from '@/components/app/AppDetailLayout'
|
||||
import { AppField } from '@/components/app/AppField'
|
||||
import { AppPageHeader } from '@/components/app/AppPageHeader'
|
||||
import { AppPriorityBadge } from '@/components/app/AppPriorityBadge'
|
||||
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 { Separator } from '@/components/ui/separator'
|
||||
import { SolutionLinkPanel } from '@/features/solutions/SolutionLinkPanel'
|
||||
|
||||
import { getTicket, transitionTicket } from './api'
|
||||
import { TicketComments } from './TicketComments'
|
||||
import { TicketMetaPanel } from './TicketMetaPanel'
|
||||
import { TicketStatusTimeline } from './TicketStatusTimeline'
|
||||
import { TicketTransitionDialog } from './TicketTransitionDialog'
|
||||
import type { Ticket, TicketStatus } from './types'
|
||||
|
||||
const NEXT_STATUSES: TicketStatus[] = ['in_progress', 'pending_confirm', 'resolved', 'closed']
|
||||
const STATUS_ACTION_LABELS: Record<TicketStatus, string> = {
|
||||
pending_info: '退回补充',
|
||||
pending: '转为待处理',
|
||||
in_progress: '开始处理',
|
||||
pending_confirm: '提交确认',
|
||||
resolved: '标记解决',
|
||||
closed: '关闭工单'
|
||||
const NEXT_STATUSES: Record<TicketStatus, TicketStatus[]> = {
|
||||
pending_info: ['pending', 'closed'],
|
||||
pending: ['in_progress', 'closed'],
|
||||
in_progress: ['pending_confirm', 'resolved', 'closed'],
|
||||
pending_confirm: ['resolved', 'in_progress', 'closed'],
|
||||
resolved: ['closed'],
|
||||
closed: []
|
||||
}
|
||||
|
||||
const STATUS_ACTIONS: Partial<Record<TicketStatus, { label: string; icon: React.ComponentType<{ className?: string }> }>> = {
|
||||
pending: { label: '转为待处理', icon: CircleDot },
|
||||
in_progress: { label: '开始处理', icon: CircleDot },
|
||||
pending_confirm: { label: '提交确认', icon: Send },
|
||||
resolved: { label: '标记解决', icon: CheckCircle2 },
|
||||
closed: { label: '关闭工单', icon: XCircle }
|
||||
}
|
||||
|
||||
export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
||||
const [ticket, setTicket] = React.useState<Ticket | null>(null)
|
||||
const [error, setError] = React.useState('')
|
||||
const [targetStatus, setTargetStatus] = React.useState<TicketStatus | null>(null)
|
||||
const [transitionDialogOpen, setTransitionDialogOpen] = React.useState(false)
|
||||
const [transitionError, setTransitionError] = React.useState('')
|
||||
|
||||
React.useEffect(() => {
|
||||
getTicket(ticketId)
|
||||
@@ -36,16 +49,28 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
||||
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : '加载失败'))
|
||||
}, [ticketId])
|
||||
|
||||
async function handleTransition(status: TicketStatus) {
|
||||
function openTransition(status: TicketStatus) {
|
||||
setTargetStatus(status)
|
||||
setTransitionError('')
|
||||
setTransitionDialogOpen(true)
|
||||
}
|
||||
|
||||
async function handleTransition(status: TicketStatus, remark: string) {
|
||||
if (!ticket) return
|
||||
const updated = await transitionTicket(ticket.id, status, `状态调整为:${STATUS_ACTION_LABELS[status]}`)
|
||||
setTicket(updated)
|
||||
try {
|
||||
const updated = await transitionTicket(ticket.id, status, remark)
|
||||
setTicket(updated)
|
||||
setTransitionError('')
|
||||
} catch (reason) {
|
||||
setTransitionError(reason instanceof Error ? reason.message : '状态流转失败')
|
||||
throw reason
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<main className="min-h-screen bg-background p-6 text-foreground">
|
||||
<AppEmptyState title="Unable to load ticket" description={error} />
|
||||
<AppEmptyState title="无法加载工单" description={error} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -53,7 +78,7 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
||||
if (!ticket) {
|
||||
return (
|
||||
<main className="min-h-screen bg-background p-6 text-foreground">
|
||||
<AppEmptyState title="Loading ticket" />
|
||||
<AppEmptyState title="正在加载工单" />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -72,49 +97,54 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
||||
}
|
||||
actions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{NEXT_STATUSES.map((status) => (
|
||||
<Button key={status} variant="secondary" onClick={() => void handleTransition(status)}>
|
||||
{STATUS_ACTION_LABELS[status]}
|
||||
</Button>
|
||||
))}
|
||||
{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 className="size-4" /> : null}
|
||||
{action?.label ?? '调整状态'}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<AppDetailLayout
|
||||
side={
|
||||
<>
|
||||
<AppSection title="关键字段">
|
||||
<div className="grid gap-3">
|
||||
<AppField label="状态">
|
||||
<AppStatusBadge status={ticket.status} />
|
||||
</AppField>
|
||||
<AppField label="优先级">
|
||||
<AppPriorityBadge priority={ticket.priority} />
|
||||
</AppField>
|
||||
<AppField label="关联系统">
|
||||
<span className="text-sm">{ticket.related_system || '-'}</span>
|
||||
</AppField>
|
||||
<AppField label="运行环境">
|
||||
<span className="text-sm">{ticket.environment || '-'}</span>
|
||||
</AppField>
|
||||
<AppField label="影响范围">
|
||||
<span className="text-sm">{ticket.impact_scope || '-'}</span>
|
||||
</AppField>
|
||||
</div>
|
||||
</AppSection>
|
||||
<TicketMetaPanel ticket={ticket} />
|
||||
<SolutionLinkPanel ticketId={ticket.id} />
|
||||
<AppSection title="AI 支持分析" action={<Badge variant="ai">后续接入</Badge>}>
|
||||
<p className="text-sm text-muted-foreground">后续阶段将在这里展示排查建议、相似工单和引用来源。</p>
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<Bot className="size-4" />
|
||||
智能排查占位
|
||||
</div>
|
||||
<Separator />
|
||||
<p className="text-sm text-muted-foreground">后续阶段将在这里展示排查建议、相似工单和引用来源。</p>
|
||||
</div>
|
||||
</AppSection>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<AppSection title="工单概览" description="当前工单的核心问题和处理上下文。">
|
||||
<AppSection title="问题描述" description="当前工单的核心问题、影响场景和处理上下文。">
|
||||
<p className="whitespace-pre-wrap text-sm leading-6">{ticket.description}</p>
|
||||
</AppSection>
|
||||
<TicketStatusTimeline ticket={ticket} />
|
||||
<TicketComments ticketId={ticket.id} />
|
||||
</AppDetailLayout>
|
||||
{transitionError ? <AppEmptyState title="状态流转失败" description={transitionError} /> : null}
|
||||
<TicketTransitionDialog
|
||||
open={transitionDialogOpen}
|
||||
status={targetStatus}
|
||||
onOpenChange={setTransitionDialogOpen}
|
||||
onSubmit={handleTransition}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user