feat: render real ticket event timeline
This commit is contained in:
@@ -16,6 +16,7 @@ from apps.tickets.schemas import (
|
|||||||
TicketAttachmentOut,
|
TicketAttachmentOut,
|
||||||
TicketCommentIn,
|
TicketCommentIn,
|
||||||
TicketCommentOut,
|
TicketCommentOut,
|
||||||
|
TicketEventOut,
|
||||||
TicketIn,
|
TicketIn,
|
||||||
TicketListOut,
|
TicketListOut,
|
||||||
TicketOut,
|
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)
|
@router.post("", response=TicketOut)
|
||||||
def create_ticket(request, payload: TicketIn):
|
def create_ticket(request, payload: TicketIn):
|
||||||
require_permission(request, "ticket.create")
|
require_permission(request, "ticket.create")
|
||||||
@@ -85,6 +98,7 @@ def create_ticket(request, payload: TicketIn):
|
|||||||
ticket=ticket,
|
ticket=ticket,
|
||||||
event_type="created",
|
event_type="created",
|
||||||
to_value={"status": ticket.status},
|
to_value={"status": ticket.status},
|
||||||
|
operator=request.user if request.user.is_authenticated else None,
|
||||||
)
|
)
|
||||||
return serialize_ticket(ticket)
|
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)
|
@router.patch("/{ticket_id}", response=TicketOut)
|
||||||
def update_ticket(request, ticket_id: int, payload: TicketUpdateIn):
|
def update_ticket(request, ticket_id: int, payload: TicketUpdateIn):
|
||||||
require_permission(request, "ticket.edit")
|
require_permission(request, "ticket.edit")
|
||||||
@@ -247,5 +268,6 @@ def add_ticket_attachment(request, ticket_id: int, payload: TicketAttachmentIn):
|
|||||||
ticket=ticket,
|
ticket=ticket,
|
||||||
event_type="attachment_added",
|
event_type="attachment_added",
|
||||||
to_value={"attachment_id": attachment.id, "file_name": attachment.file_name},
|
to_value={"attachment_id": attachment.id, "file_name": attachment.file_name},
|
||||||
|
operator=request.user if request.user.is_authenticated else None,
|
||||||
)
|
)
|
||||||
return attachment
|
return attachment
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ class TicketEventOut(Schema):
|
|||||||
from_value: dict
|
from_value: dict
|
||||||
to_value: dict
|
to_value: dict
|
||||||
remark: str
|
remark: str
|
||||||
|
operator: TicketUserOut | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -127,3 +127,38 @@ def test_transition_ticket_requires_non_empty_remark(admin_client, db):
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 400
|
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'
|
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 [content, setContent] = React.useState('')
|
||||||
const [lastComment, setLastComment] = React.useState('')
|
const [lastComment, setLastComment] = React.useState('')
|
||||||
const [commentMode, setCommentMode] = React.useState('internal')
|
const [commentMode, setCommentMode] = React.useState('internal')
|
||||||
@@ -21,6 +26,7 @@ export function TicketComments({ ticketId }: { ticketId: number }) {
|
|||||||
})
|
})
|
||||||
setLastComment(comment.content)
|
setLastComment(comment.content)
|
||||||
setContent('')
|
setContent('')
|
||||||
|
onCommentAdded?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { SolutionLinkPanel } from '@/features/solutions/SolutionLinkPanel'
|
import { SolutionLinkPanel } from '@/features/solutions/SolutionLinkPanel'
|
||||||
|
|
||||||
import { getTicket, transitionTicket } from './api'
|
import { getTicket, listTicketEvents, transitionTicket } from './api'
|
||||||
import { TicketComments } from './TicketComments'
|
import { TicketComments } from './TicketComments'
|
||||||
import { TicketMetaPanel } from './TicketMetaPanel'
|
import { TicketMetaPanel } from './TicketMetaPanel'
|
||||||
import { TicketStatusTimeline } from './TicketStatusTimeline'
|
import { TicketStatusTimeline } from './TicketStatusTimeline'
|
||||||
import { TicketTransitionDialog } from './TicketTransitionDialog'
|
import { TicketTransitionDialog } from './TicketTransitionDialog'
|
||||||
import type { Ticket, TicketStatus } from './types'
|
import type { Ticket, TicketEvent, TicketStatus } from './types'
|
||||||
|
|
||||||
const NEXT_STATUSES: Record<TicketStatus, TicketStatus[]> = {
|
const NEXT_STATUSES: Record<TicketStatus, TicketStatus[]> = {
|
||||||
pending_info: ['pending', 'closed'],
|
pending_info: ['pending', 'closed'],
|
||||||
@@ -45,6 +45,7 @@ function getStatusActionLabel(currentStatus: TicketStatus, targetStatus: TicketS
|
|||||||
|
|
||||||
export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
||||||
const [ticket, setTicket] = React.useState<Ticket | null>(null)
|
const [ticket, setTicket] = React.useState<Ticket | null>(null)
|
||||||
|
const [events, setEvents] = React.useState<TicketEvent[]>([])
|
||||||
const [error, setError] = React.useState('')
|
const [error, setError] = React.useState('')
|
||||||
const [targetStatus, setTargetStatus] = React.useState<TicketStatus | null>(null)
|
const [targetStatus, setTargetStatus] = React.useState<TicketStatus | null>(null)
|
||||||
const [targetActionLabel, setTargetActionLabel] = React.useState('')
|
const [targetActionLabel, setTargetActionLabel] = React.useState('')
|
||||||
@@ -55,8 +56,13 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
|||||||
getTicket(ticketId)
|
getTicket(ticketId)
|
||||||
.then(setTicket)
|
.then(setTicket)
|
||||||
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : '加载失败'))
|
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : '加载失败'))
|
||||||
|
refreshEvents()
|
||||||
}, [ticketId])
|
}, [ticketId])
|
||||||
|
|
||||||
|
function refreshEvents() {
|
||||||
|
listTicketEvents(ticketId).then(setEvents).catch(() => setEvents([]))
|
||||||
|
}
|
||||||
|
|
||||||
function openTransition(status: TicketStatus) {
|
function openTransition(status: TicketStatus) {
|
||||||
setTargetStatus(status)
|
setTargetStatus(status)
|
||||||
if (ticket) {
|
if (ticket) {
|
||||||
@@ -72,6 +78,7 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
|||||||
const updated = await transitionTicket(ticket.id, status, remark)
|
const updated = await transitionTicket(ticket.id, status, remark)
|
||||||
setTicket(updated)
|
setTicket(updated)
|
||||||
setTransitionError('')
|
setTransitionError('')
|
||||||
|
refreshEvents()
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
setTransitionError(reason instanceof Error ? reason.message : '状态流转失败')
|
setTransitionError(reason instanceof Error ? reason.message : '状态流转失败')
|
||||||
throw reason
|
throw reason
|
||||||
@@ -146,8 +153,8 @@ export function TicketDetailPage({ ticketId }: { ticketId: number }) {
|
|||||||
<AppSection title="问题描述">
|
<AppSection title="问题描述">
|
||||||
<p className="whitespace-pre-wrap text-sm leading-6">{ticket.description}</p>
|
<p className="whitespace-pre-wrap text-sm leading-6">{ticket.description}</p>
|
||||||
</AppSection>
|
</AppSection>
|
||||||
<TicketStatusTimeline ticket={ticket} />
|
<TicketStatusTimeline ticket={ticket} events={events} />
|
||||||
<TicketComments ticketId={ticket.id} />
|
<TicketComments ticketId={ticket.id} onCommentAdded={refreshEvents} />
|
||||||
</AppDetailLayout>
|
</AppDetailLayout>
|
||||||
{transitionError ? <AppEmptyState title="状态流转失败" description={transitionError} /> : null}
|
{transitionError ? <AppEmptyState title="状态流转失败" description={transitionError} /> : null}
|
||||||
<TicketTransitionDialog
|
<TicketTransitionDialog
|
||||||
|
|||||||
@@ -2,44 +2,117 @@ import { AppSection } from '@/components/app/AppSection'
|
|||||||
import { AppStatusBadge } from '@/components/app/AppStatusBadge'
|
import { AppStatusBadge } from '@/components/app/AppStatusBadge'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
|
||||||
import type { Ticket } from './types'
|
import type { Ticket, TicketEvent } from './types'
|
||||||
|
|
||||||
const EVENT_LABELS = [
|
const EVENT_LABELS: Record<string, string> = {
|
||||||
{ label: '创建工单', description: '工单已进入支持队列。' },
|
created: '创建工单',
|
||||||
{ label: '状态变更', description: '处理状态会在这里沉淀为时间线。' },
|
updated: '更新工单',
|
||||||
{ label: '新增评论', description: '内部备注和用户可见回复会记录协作过程。' },
|
status_change: '状态变更',
|
||||||
{ label: '新增附件', description: '后续附件能力接入后展示上传记录。' }
|
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 (
|
return (
|
||||||
<AppSection title="处理记录">
|
<AppSection title="处理记录">
|
||||||
<div className="grid gap-3">
|
<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="flex items-center justify-between gap-3 rounded-md border border-border bg-background px-3 py-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-medium text-foreground">当前状态</p>
|
<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>
|
</div>
|
||||||
<AppStatusBadge status={ticket.status} />
|
<AppStatusBadge status={ticket.status} />
|
||||||
</div>
|
</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">
|
<div className="grid gap-2">
|
||||||
{EVENT_LABELS.map((event, index) => (
|
{events.map((event, index) => (
|
||||||
<div key={event.label} className="grid grid-cols-[auto_minmax(0,1fr)] gap-3">
|
<div key={event.id} className="grid grid-cols-[auto_minmax(0,1fr)] gap-3">
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div className="size-2 rounded-full bg-primary" />
|
<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>
|
||||||
<div className="pb-3">
|
<div className="pb-3">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<p className="text-sm font-medium text-foreground">{event.label}</p>
|
<p className="text-sm font-medium text-foreground">
|
||||||
{index === 0 ? <Badge variant="secondary">{ticket.ticket_no}</Badge> : null}
|
{EVENT_LABELS[event.event_type] ?? event.event_type}
|
||||||
|
</p>
|
||||||
|
{event.event_type === 'created' ? <Badge variant="secondary">{ticket.ticket_no}</Badge> : null}
|
||||||
</div>
|
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</AppSection>
|
</AppSection>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { apiRequest } from '@/lib/api'
|
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 = {}) {
|
export function listTickets(params: TicketListParams = {}) {
|
||||||
const query = new URLSearchParams()
|
const query = new URLSearchParams()
|
||||||
@@ -24,6 +32,10 @@ export function getTicket(ticketId: number) {
|
|||||||
return apiRequest<Ticket>(`/tickets/${ticketId}`)
|
return apiRequest<Ticket>(`/tickets/${ticketId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listTicketEvents(ticketId: number) {
|
||||||
|
return apiRequest<TicketEvent[]>(`/tickets/${ticketId}/events`)
|
||||||
|
}
|
||||||
|
|
||||||
export function transitionTicket(ticketId: number, status: TicketStatus, remark = '') {
|
export function transitionTicket(ticketId: number, status: TicketStatus, remark = '') {
|
||||||
return apiRequest<Ticket>(`/tickets/${ticketId}/status`, {
|
return apiRequest<Ticket>(`/tickets/${ticketId}/status`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -69,3 +69,13 @@ export type TicketComment = {
|
|||||||
visibility: string
|
visibility: string
|
||||||
created_at: 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