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

@@ -16,6 +16,7 @@ from apps.tickets.schemas import (
TicketAttachmentOut,
TicketCommentIn,
TicketCommentOut,
TicketEventOut,
TicketIn,
TicketListOut,
TicketOut,
@@ -63,6 +64,18 @@ def serialize_ticket(ticket: Ticket) -> dict:
}
def serialize_ticket_event(event: TicketEvent) -> dict:
return {
"id": event.id,
"event_type": event.event_type,
"from_value": event.from_value,
"to_value": event.to_value,
"remark": event.remark,
"operator": serialize_ticket_user(event.operator),
"created_at": event.created_at,
}
@router.post("", response=TicketOut)
def create_ticket(request, payload: TicketIn):
require_permission(request, "ticket.create")
@@ -85,6 +98,7 @@ def create_ticket(request, payload: TicketIn):
ticket=ticket,
event_type="created",
to_value={"status": ticket.status},
operator=request.user if request.user.is_authenticated else None,
)
return serialize_ticket(ticket)
@@ -175,6 +189,13 @@ def get_ticket(request, ticket_id: int):
)
@router.get("/{ticket_id}/events", response=list[TicketEventOut])
def list_ticket_events(request, ticket_id: int):
require_permission(request, "ticket.view")
events = TicketEvent.objects.select_related("operator").filter(ticket_id=ticket_id)
return [serialize_ticket_event(event) for event in events]
@router.patch("/{ticket_id}", response=TicketOut)
def update_ticket(request, ticket_id: int, payload: TicketUpdateIn):
require_permission(request, "ticket.edit")
@@ -247,5 +268,6 @@ def add_ticket_attachment(request, ticket_id: int, payload: TicketAttachmentIn):
ticket=ticket,
event_type="attachment_added",
to_value={"attachment_id": attachment.id, "file_name": attachment.file_name},
operator=request.user if request.user.is_authenticated else None,
)
return attachment

View File

@@ -66,6 +66,7 @@ class TicketEventOut(Schema):
from_value: dict
to_value: dict
remark: str
operator: TicketUserOut | None = None
created_at: datetime

View File

@@ -127,3 +127,38 @@ def test_transition_ticket_requires_non_empty_remark(admin_client, db):
)
assert response.status_code == 400
def test_get_ticket_events_returns_real_timeline_with_operator(admin_client, db):
user_model = get_user_model()
operator = user_model.objects.get(username="admin")
ticket = Ticket.objects.create(
ticket_no="TK202607020200",
title="A",
description="A",
status="in_progress",
)
TicketEvent.objects.create(
ticket=ticket,
event_type="created",
to_value={"status": "pending"},
operator=operator,
)
TicketEvent.objects.create(
ticket=ticket,
event_type="status_change",
from_value={"status": "pending"},
to_value={"status": "in_progress"},
operator=operator,
remark="开始排查认证链路",
)
response = admin_client.get(f"/api/tickets/{ticket.id}/events")
assert response.status_code == 200
payload = response.json()
assert [event["event_type"] for event in payload] == ["created", "status_change"]
assert payload[1]["from_value"] == {"status": "pending"}
assert payload[1]["to_value"] == {"status": "in_progress"}
assert payload[1]["remark"] == "开始排查认证链路"
assert payload[1]["operator"]["display_name"] == "admin"

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>
{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">
{EVENT_LABELS.map((event, index) => (
<div key={event.label} className="grid grid-cols-[auto_minmax(0,1fr)] gap-3">
{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 < EVENT_LABELS.length - 1 ? <div className="h-full min-h-7 w-px bg-border" /> : null}
{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.label}</p>
{index === 0 ? <Badge variant="secondary">{ticket.ticket_no}</Badge> : null}
<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-xs text-muted-foreground">{event.description}</p>
<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>
</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
}