diff --git a/backend/apps/tickets/api.py b/backend/apps/tickets/api.py index 8ffea76..9fb594a 100644 --- a/backend/apps/tickets/api.py +++ b/backend/apps/tickets/api.py @@ -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 diff --git a/backend/apps/tickets/schemas.py b/backend/apps/tickets/schemas.py index d825419..9c54ee2 100644 --- a/backend/apps/tickets/schemas.py +++ b/backend/apps/tickets/schemas.py @@ -66,6 +66,7 @@ class TicketEventOut(Schema): from_value: dict to_value: dict remark: str + operator: TicketUserOut | None = None created_at: datetime diff --git a/backend/apps/tickets/tests/test_ticket_api.py b/backend/apps/tickets/tests/test_ticket_api.py index fd066a9..565da02 100644 --- a/backend/apps/tickets/tests/test_ticket_api.py +++ b/backend/apps/tickets/tests/test_ticket_api.py @@ -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" diff --git a/frontend/src/features/tickets/TicketComments.tsx b/frontend/src/features/tickets/TicketComments.tsx index 964b9da..41661ec 100644 --- a/frontend/src/features/tickets/TicketComments.tsx +++ b/frontend/src/features/tickets/TicketComments.tsx @@ -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 ( diff --git a/frontend/src/features/tickets/TicketDetailPage.tsx b/frontend/src/features/tickets/TicketDetailPage.tsx index 109bfb6..7ffafa0 100644 --- a/frontend/src/features/tickets/TicketDetailPage.tsx +++ b/frontend/src/features/tickets/TicketDetailPage.tsx @@ -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 = { 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(null) + const [events, setEvents] = React.useState([]) const [error, setError] = React.useState('') const [targetStatus, setTargetStatus] = React.useState(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 }) {

{ticket.description}

- - + + {transitionError ? : null} = { + created: '创建工单', + updated: '更新工单', + status_change: '状态变更', + commented: '新增评论', + attachment_added: '新增附件' +} -export function TicketStatusTimeline({ ticket }: { ticket: Ticket }) { +const STATUS_LABELS: Record = { + pending_info: '待补充', + pending: '待处理', + in_progress: '处理中', + pending_confirm: '待确认', + resolved: '已解决', + closed: '已关闭' +} + +const COMMENT_TYPE_LABELS: Record = { + 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 (

当前状态

-

最后更新时间:{new Date(ticket.updated_at).toLocaleString('zh-CN')}

+

+ 最后更新时间:{new Date(ticket.updated_at).toLocaleString('zh-CN')} +

-
- {EVENT_LABELS.map((event, index) => ( -
-
-
- {index < EVENT_LABELS.length - 1 ?
: null} -
-
-
-

{event.label}

- {index === 0 ? {ticket.ticket_no} : null} + {events.length === 0 ? ( +
+

暂无处理记录

+
+ ) : ( +
+ {events.map((event, index) => ( +
+
+
+ {index < events.length - 1 ?
: null} +
+
+
+

+ {EVENT_LABELS[event.event_type] ?? event.event_type} +

+ {event.event_type === 'created' ? {ticket.ticket_no} : null} +
+

{describeEvent(event)}

+ {event.remark ?

说明:{event.remark}

: null} +

+ {displayOperator(event)} · {formatEventTime(event.created_at)} +

-

{event.description}

-
- ))} -
+ ))} +
+ )}
) diff --git a/frontend/src/features/tickets/api.ts b/frontend/src/features/tickets/api.ts index 9169a68..08e5f26 100644 --- a/frontend/src/features/tickets/api.ts +++ b/frontend/src/features/tickets/api.ts @@ -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(`/tickets/${ticketId}`) } +export function listTicketEvents(ticketId: number) { + return apiRequest(`/tickets/${ticketId}/events`) +} + export function transitionTicket(ticketId: number, status: TicketStatus, remark = '') { return apiRequest(`/tickets/${ticketId}/status`, { method: 'POST', diff --git a/frontend/src/features/tickets/types.ts b/frontend/src/features/tickets/types.ts index d8fe4e8..c71ee09 100644 --- a/frontend/src/features/tickets/types.ts +++ b/frontend/src/features/tickets/types.ts @@ -69,3 +69,13 @@ export type TicketComment = { visibility: string created_at: string } + +export type TicketEvent = { + id: number + event_type: string + from_value: Record + to_value: Record + remark: string + operator: TicketUser | null + created_at: string +}