feat: render real ticket event timeline
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -66,6 +66,7 @@ class TicketEventOut(Schema):
|
||||
from_value: dict
|
||||
to_value: dict
|
||||
remark: str
|
||||
operator: TicketUserOut | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user