feat: render real ticket event timeline

This commit is contained in:
hailin
2026-07-02 10:36:52 +08:00
parent a6df96ebb0
commit 399a5bdb73
8 changed files with 196 additions and 30 deletions

View File

@@ -7,7 +7,12 @@ import { Textarea } from '@/components/ui/textarea'
import { addTicketComment } from './api'
export function TicketComments({ ticketId }: { ticketId: number }) {
type TicketCommentsProps = {
ticketId: number
onCommentAdded?: () => void
}
export function TicketComments({ ticketId, onCommentAdded }: TicketCommentsProps) {
const [content, setContent] = React.useState('')
const [lastComment, setLastComment] = React.useState('')
const [commentMode, setCommentMode] = React.useState('internal')
@@ -21,6 +26,7 @@ export function TicketComments({ ticketId }: { ticketId: number }) {
})
setLastComment(comment.content)
setContent('')
onCommentAdded?.()
}
return (

View File

@@ -12,12 +12,12 @@ import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { SolutionLinkPanel } from '@/features/solutions/SolutionLinkPanel'
import { getTicket, transitionTicket } from './api'
import { getTicket, listTicketEvents, 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'
import type { Ticket, TicketEvent, TicketStatus } from './types'
const NEXT_STATUSES: Record<TicketStatus, TicketStatus[]> = {
pending_info: ['pending', 'closed'],
@@ -45,6 +45,7 @@ function getStatusActionLabel(currentStatus: TicketStatus, targetStatus: TicketS
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('')
@@ -55,8 +56,13 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
getTicket(ticketId)
.then(setTicket)
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : '加载失败'))
refreshEvents()
}, [ticketId])
function refreshEvents() {
listTicketEvents(ticketId).then(setEvents).catch(() => setEvents([]))
}
function openTransition(status: TicketStatus) {
setTargetStatus(status)
if (ticket) {
@@ -72,6 +78,7 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
const updated = await transitionTicket(ticket.id, status, remark)
setTicket(updated)
setTransitionError('')
refreshEvents()
} catch (reason) {
setTransitionError(reason instanceof Error ? reason.message : '状态流转失败')
throw reason
@@ -146,8 +153,8 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
<AppSection title="问题描述">
<p className="whitespace-pre-wrap text-sm leading-6">{ticket.description}</p>
</AppSection>
<TicketStatusTimeline ticket={ticket} />
<TicketComments ticketId={ticket.id} />
<TicketStatusTimeline ticket={ticket} events={events} />
<TicketComments ticketId={ticket.id} onCommentAdded={refreshEvents} />
</AppDetailLayout>
{transitionError ? <AppEmptyState title="状态流转失败" description={transitionError} /> : null}
<TicketTransitionDialog

View File

@@ -2,44 +2,117 @@ import { AppSection } from '@/components/app/AppSection'
import { AppStatusBadge } from '@/components/app/AppStatusBadge'
import { Badge } from '@/components/ui/badge'
import type { Ticket } from './types'
import type { Ticket, TicketEvent } from './types'
const EVENT_LABELS = [
{ label: '创建工单', description: '工单已进入支持队列。' },
{ label: '状态变更', description: '处理状态会在这里沉淀为时间线。' },
{ label: '新增评论', description: '内部备注和用户可见回复会记录协作过程。' },
{ label: '新增附件', description: '后续附件能力接入后展示上传记录。' }
]
const EVENT_LABELS: Record<string, string> = {
created: '创建工单',
updated: '更新工单',
status_change: '状态变更',
commented: '新增评论',
attachment_added: '新增附件'
}
export function TicketStatusTimeline({ ticket }: { ticket: Ticket }) {
const STATUS_LABELS: Record<string, string> = {
pending_info: '待补充',
pending: '待处理',
in_progress: '处理中',
pending_confirm: '待确认',
resolved: '已解决',
closed: '已关闭'
}
const COMMENT_TYPE_LABELS: Record<string, string> = {
internal_note: '内部备注',
user_reply: '用户可见回复',
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))
}
function displayStatus(value: unknown) {
if (typeof value !== 'string') return ''
return STATUS_LABELS[value] ?? value
}
function displayOperator(event: TicketEvent) {
return event.operator?.display_name || event.operator?.username || '系统'
}
function describeEvent(event: TicketEvent) {
if (event.event_type === 'created') {
return '工单已进入支持队列。'
}
if (event.event_type === 'status_change') {
const fromStatus = displayStatus(event.from_value.status)
const toStatus = displayStatus(event.to_value.status)
return fromStatus && toStatus ? `${fromStatus} -> ${toStatus}` : '状态已更新'
}
if (event.event_type === 'commented') {
const commentType = event.to_value.comment_type
return typeof commentType === 'string' ? COMMENT_TYPE_LABELS[commentType] ?? commentType : '已新增评论'
}
if (event.event_type === 'attachment_added') {
const fileName = event.to_value.file_name
return typeof fileName === 'string' ? fileName : '已新增附件'
}
return event.remark || '记录已更新'
}
type TicketStatusTimelineProps = {
ticket: Ticket
events: TicketEvent[]
}
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="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>
<p className="mt-1 text-xs text-muted-foreground">
最后更新时间:{new Date(ticket.updated_at).toLocaleString('zh-CN')}
</p>
</div>
<AppStatusBadge status={ticket.status} />
</div>
<div className="grid gap-2">
{EVENT_LABELS.map((event, index) => (
<div key={event.label} 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 < EVENT_LABELS.length - 1 ? <div className="h-full min-h-7 w-px bg-border" /> : null}
</div>
<div className="pb-3">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-medium text-foreground">{event.label}</p>
{index === 0 ? <Badge variant="secondary">{ticket.ticket_no}</Badge> : null}
{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">
{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}
</div>
<div className="pb-3">
<div className="flex 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}
</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>
<p className="mt-1 text-xs text-muted-foreground">{event.description}</p>
</div>
</div>
))}
</div>
))}
</div>
)}
</div>
</AppSection>
)

View File

@@ -1,6 +1,14 @@
import { apiRequest } from '@/lib/api'
import type { Ticket, TicketComment, TicketInput, TicketListParams, TicketListResponse, TicketStatus } from './types'
import type {
Ticket,
TicketComment,
TicketEvent,
TicketInput,
TicketListParams,
TicketListResponse,
TicketStatus
} from './types'
export function listTickets(params: TicketListParams = {}) {
const query = new URLSearchParams()
@@ -24,6 +32,10 @@ export function getTicket(ticketId: number) {
return apiRequest<Ticket>(`/tickets/${ticketId}`)
}
export function listTicketEvents(ticketId: number) {
return apiRequest<TicketEvent[]>(`/tickets/${ticketId}/events`)
}
export function transitionTicket(ticketId: number, status: TicketStatus, remark = '') {
return apiRequest<Ticket>(`/tickets/${ticketId}/status`, {
method: 'POST',

View File

@@ -69,3 +69,13 @@ export type TicketComment = {
visibility: string
created_at: string
}
export type TicketEvent = {
id: number
event_type: string
from_value: Record<string, unknown>
to_value: Record<string, unknown>
remark: string
operator: TicketUser | null
created_at: string
}