274 lines
11 KiB
TypeScript
274 lines
11 KiB
TypeScript
import * as React from 'react'
|
|
import { CheckCircle2, CircleDot, Clock3, Send, XCircle } from 'lucide-react'
|
|
|
|
import { AppEmptyState } from '@/components/app/AppEmptyState'
|
|
import { AppDetailLayout } from '@/components/app/AppDetailLayout'
|
|
import { AppPage } from '@/components/app/AppPage'
|
|
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 { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { useAgentPageContext } from '@/features/agent-workspace/AgentWorkspaceProvider'
|
|
import { SolutionLinkPanel } from '@/features/solutions/SolutionLinkPanel'
|
|
import { selectDefaultTicketSolution } from '@/features/solutions/solutionSelection'
|
|
import type { TicketSolution } from '@/features/solutions/types'
|
|
import { formatDateTime } from '@/lib/format'
|
|
|
|
import { getTicket, listTicketEvents, transitionTicket } from './api'
|
|
import { TicketComments } from './TicketComments'
|
|
import { getTicketCategoryLabel, getTicketNextStatuses } from './ticketCategories'
|
|
import { TicketMetaPanel } from './TicketMetaPanel'
|
|
import { TicketStatusTimeline } from './TicketStatusTimeline'
|
|
import { TicketTransitionDialog } from './TicketTransitionDialog'
|
|
import type { Ticket, TicketEvent, TicketStatus } from './types'
|
|
|
|
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 }
|
|
}
|
|
|
|
function getStatusActionLabel(currentStatus: TicketStatus, targetStatus: TicketStatus) {
|
|
if (targetStatus === 'closed' && currentStatus !== 'resolved') {
|
|
return '取消工单'
|
|
}
|
|
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[]>([])
|
|
const [error, setError] = React.useState('')
|
|
const [targetStatus, setTargetStatus] = React.useState<TicketStatus | null>(null)
|
|
const [targetActionLabel, setTargetActionLabel] = React.useState('')
|
|
const [transitionDialogOpen, setTransitionDialogOpen] = React.useState(false)
|
|
const [transitionError, setTransitionError] = React.useState('')
|
|
const [linkedSolutions, setLinkedSolutions] = React.useState<TicketSolution[]>([])
|
|
const agentContext = React.useMemo(() => ticket ? ({
|
|
routeKey: 'ticket-detail', entityType: 'ticket', entityId: ticket.ticket_no, title: ticket.title, status: ticket.status,
|
|
metadata: { 优先级: ticket.priority, 关联系统: ticket.related_system || '未填写', 问题对象: ticket.problem_object || '未填写' }
|
|
}) : null, [ticket])
|
|
useAgentPageContext(agentContext)
|
|
|
|
React.useEffect(() => {
|
|
refreshTicket()
|
|
refreshEvents()
|
|
}, [ticketId])
|
|
|
|
function refreshTicket() {
|
|
getTicket(ticketId)
|
|
.then(setTicket)
|
|
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : '加载失败'))
|
|
}
|
|
|
|
function refreshEvents() {
|
|
listTicketEvents(ticketId).then(setEvents).catch(() => setEvents([]))
|
|
}
|
|
|
|
function openTransition(status: TicketStatus) {
|
|
setTargetStatus(status)
|
|
if (ticket) {
|
|
setTargetActionLabel(getStatusActionLabel(ticket.status, status))
|
|
}
|
|
setTransitionError('')
|
|
setTransitionDialogOpen(true)
|
|
}
|
|
|
|
async function handleTransition(
|
|
status: TicketStatus,
|
|
values: {
|
|
remark: string
|
|
resolution: string
|
|
is_resolved?: boolean
|
|
replied_to_customer: boolean
|
|
}
|
|
) {
|
|
if (!ticket) return
|
|
try {
|
|
const updated = await transitionTicket(ticket.id, status, values)
|
|
setTicket(updated)
|
|
setTransitionError('')
|
|
refreshEvents()
|
|
} catch (reason) {
|
|
setTransitionError(reason instanceof Error ? reason.message : '状态流转失败')
|
|
throw reason
|
|
}
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<AppPage maxWidth="6xl" header={<AppPageHeader title="工单详情" />}>
|
|
<AppEmptyState title="无法加载工单" description={error} />
|
|
</AppPage>
|
|
)
|
|
}
|
|
|
|
if (!ticket) {
|
|
return (
|
|
<AppPage maxWidth="6xl" header={<AppPageHeader title="工单详情" />}>
|
|
<AppEmptyState title="正在加载工单" />
|
|
</AppPage>
|
|
)
|
|
}
|
|
|
|
const nextStatuses = getTicketNextStatuses(ticket.category_code, ticket.status)
|
|
const defaultLinkedSolution = selectDefaultTicketSolution(linkedSolutions)
|
|
const transitionDefaultResolution =
|
|
targetStatus === 'resolved' || targetStatus === 'closed'
|
|
? ticket.resolution || defaultLinkedSolution?.solution.solution || ''
|
|
: ''
|
|
const transitionDefaultRemark =
|
|
targetStatus === 'closed' && transitionDefaultResolution && !ticket.resolution
|
|
? '根据关联解决方案关闭工单'
|
|
: ''
|
|
|
|
return (
|
|
<AppPage
|
|
maxWidth="6xl"
|
|
header={<AppPageHeader
|
|
title={ticket.title}
|
|
description={ticket.ticket_no}
|
|
breadcrumbLabel={ticket.ticket_no}
|
|
meta={
|
|
<>
|
|
<AppStatusBadge status={ticket.status} />
|
|
<AppPriorityBadge priority={ticket.priority} />
|
|
</>
|
|
}
|
|
actions={
|
|
<div className="flex flex-wrap gap-2">
|
|
{nextStatuses.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} />
|
|
<AppDetailLayout
|
|
side={
|
|
<>
|
|
<TicketMetaPanel ticket={ticket} />
|
|
<SolutionLinkPanel
|
|
ticketId={ticket.id}
|
|
onLinkedSolutionsChange={setLinkedSolutions}
|
|
onTicketChanged={() => {
|
|
refreshTicket()
|
|
refreshEvents()
|
|
}}
|
|
/>
|
|
</>
|
|
}
|
|
>
|
|
<AppSection variant="card" title="问题描述">
|
|
<div className="grid gap-4">
|
|
<div>
|
|
<p className="text-xs font-medium text-muted-foreground">问题分类</p>
|
|
<p className="mt-1 text-sm font-medium text-foreground">{getTicketCategoryLabel(ticket.category_code)}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs font-medium text-muted-foreground">问题表现</p>
|
|
<p className="mt-1 whitespace-pre-wrap text-sm leading-6">{ticket.problem_performance || '未填写'}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs font-medium text-muted-foreground">问题描述</p>
|
|
<p className="mt-1 whitespace-pre-wrap text-sm leading-6">{ticket.description}</p>
|
|
</div>
|
|
</div>
|
|
</AppSection>
|
|
<AppSection variant="card" title="解决方案">
|
|
{ticket.resolution ? (
|
|
<div className="grid gap-3">
|
|
<p className="whitespace-pre-wrap text-sm leading-6">{ticket.resolution}</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Badge variant={ticket.is_resolved ? 'success' : 'outline'}>
|
|
{ticket.is_resolved ? '已解决' : '未解决'}
|
|
</Badge>
|
|
<Badge variant={ticket.replied_to_customer ? 'info' : 'outline'}>
|
|
{ticket.replied_to_customer ? '已回复客户' : '未回复客户'}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<AppEmptyState title="暂无解决方案" description="解决工单后将在这里展示处理结论。" />
|
|
)}
|
|
</AppSection>
|
|
<TicketStatusTimeline ticket={ticket} events={events} />
|
|
<TicketComments ticketId={ticket.id} onCommentAdded={refreshEvents} />
|
|
</AppDetailLayout>
|
|
{transitionError ? <AppEmptyState title="状态流转失败" description={transitionError} /> : null}
|
|
<TicketTransitionDialog
|
|
open={transitionDialogOpen}
|
|
status={targetStatus}
|
|
actionLabel={targetActionLabel}
|
|
defaultResolution={transitionDefaultResolution}
|
|
defaultRemark={transitionDefaultRemark}
|
|
onOpenChange={setTransitionDialogOpen}
|
|
onSubmit={handleTransition}
|
|
/>
|
|
</AppPage>
|
|
)
|
|
}
|
|
|
|
function TicketOverviewCard({ ticket }: { ticket: Ticket }) {
|
|
const items = [
|
|
{ label: '问题分类', value: getTicketCategoryLabel(ticket.category_code) },
|
|
{ label: '问题对象', value: ticket.problem_object || '未填写' },
|
|
{ label: '提出人', value: ticket.submitter_name || displayUser(ticket.requester) },
|
|
{ label: '提出机构', value: ticket.submitter_org || '未填写' },
|
|
{ label: '请求人', value: displayUser(ticket.requester) },
|
|
{ label: '处理人', value: displayUser(ticket.assignee) },
|
|
{ label: '关联系统', value: ticket.related_system || '未填写' },
|
|
{ label: '更新时间', value: formatDateTime(ticket.updated_at) }
|
|
]
|
|
|
|
return (
|
|
<Card className="gap-0 py-0">
|
|
<CardHeader className="border-b bg-muted/30 py-3.5">
|
|
<CardTitle className="text-sm font-semibold">工单概览</CardTitle>
|
|
<CardDescription className="text-xs">提出方、责任人与业务上下文</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="p-4">
|
|
<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>
|
|
</CardContent>
|
|
<CardFooter className="justify-between bg-muted/45 px-4 py-3 text-xs text-muted-foreground">
|
|
<span className="flex items-center gap-2"><Clock3 className="size-4" />创建于 {formatDateTime(ticket.created_at)}</span>
|
|
<span className="font-mono">{ticket.ticket_no}</span>
|
|
</CardFooter>
|
|
</Card>
|
|
)
|
|
}
|