接单工单新增流转记录(订单详情时间线)

- 后端:新增工单事件查询接口(管理端 + 打手端),事件带操作人显示名(管理员用户名 / 打手昵称 / 系统)
- 补齐关键节点事件:抢单接手(grabbed)、验收通过(accepted 带操作人)
- 管理端/打手端事件写入操作人(admin session username 贯通至事件)
- 前端:订单详情新增「流转记录」时间线,按订单生命周期节点展示(创建/发单/接手/资料/验收/问题/取消),时间+操作人+状态流转
This commit is contained in:
yml2213
2026-08-16 19:25:20 +08:00
parent 81ae04dae0
commit 950c4fd578
13 changed files with 699 additions and 92 deletions
@@ -0,0 +1,267 @@
import { ClockCircleOutlined } from '@ant-design/icons'
import { useQuery } from '@tanstack/react-query'
import { Empty, Skeleton, Tag, Timeline, Tooltip, Typography } from 'antd'
import dayjs from '@/lib/dayjs'
import { fetchAdminWorkOrderEvents } from '@/services/admin'
import { fetchWorkerMyOrderEvents } from '@/services/worker'
import type { WorkOrderEvent } from '@/types/worker-platform'
type EventNodeStyle = {
/** 节点标题(主文案) */
title: string
/** 补充说明(次文案) */
description?: string
/** Timeline 节点颜色 */
color: string
}
/** 工单状态中文名 */
const STATUS_LABELS: Record<string, string> = {
pending_material: '待完善资料',
unassigned: '未分配',
open: '待抢单',
in_progress: '代练中',
pending_acceptance: '待验收',
problem: '问题单',
accepted: '已验收',
cancelled: '已取消',
}
function formatStatusLabel(status: string) {
return STATUS_LABELS[status] || status || '未知'
}
function readPayload(event: WorkOrderEvent, key: string): string {
const value = event.payload?.[key]
if (value === undefined || value === null) return ''
return String(value).trim()
}
/** 事件类型 → 节点阶段文案与颜色 */
export function formatWorkOrderEvent(event: WorkOrderEvent): EventNodeStyle {
const type = event.eventType
const note = readPayload(event, 'note') || readPayload(event, 'reason')
switch (type) {
// —— 创建节点 ——
case 'source_synced':
return {
title: '系统收到推送的新订单,自动生成该笔订单',
color: 'blue',
}
case 'mock_created':
return { title: '本地 Mock 生成该笔订单', color: 'blue' }
// —— 发布节点 ——
case 'published':
return { title: '将订单发单至内部接单大厅', color: 'geekblue' }
case 'unpublished':
return { title: '将订单下架,退回未分配状态', color: 'geekblue' }
case 'pinned':
return { title: '将订单置顶,抢单大厅优先展示', color: 'gold' }
case 'unpinned':
return { title: '取消订单置顶', color: 'gold' }
// —— 接手节点 ——
case 'grabbed': {
const workerName = readPayload(event, 'workerName')
return {
title: workerName
? `通过接单大厅自助接手此单,该订单当前接手人:${workerName}`
: '通过接单大厅自助接手此单',
description: `订单状态:${formatStatusLabel(event.fromStatus)}${formatStatusLabel(
event.toStatus,
)}`,
color: 'green',
}
}
case 'assigned_by_admin': {
const workerId = readPayload(event, 'workerId')
return {
title: `指派打手接手此单${workerId ? `(打手 #${workerId}` : ''}`,
description: `订单状态:${formatStatusLabel(event.fromStatus)}${formatStatusLabel(
event.toStatus,
)}`,
color: 'green',
}
}
case 'unassigned_by_admin':
return { title: '取消指派,订单回到未分配状态', color: 'green' }
case 'returned_to_hall_by_admin':
return {
title: note ? `撤回订单到抢单大厅(${note}` : '撤回订单到抢单大厅',
color: 'green',
}
case 'cancelled_by_worker':
return { title: '打手取消接单', color: 'red' }
case 'timeout_reopen':
return { title: '订单超时未完成,已退回抢单大厅', color: 'orange' }
// —— 资料节点 ——
case 'material_submitted':
return { title: '提交了订单资料', color: 'cyan' }
case 'material_completed':
return { title: '完善了订单资料', color: 'cyan' }
case 'material_updated':
return { title: '更新了订单资料', color: 'cyan' }
// —— 验收节点 ——
case 'acceptance_submitted': {
const imageCount = readPayload(event, 'imageCount')
return {
title: `提交验收资料${imageCount ? `${imageCount} 张图)` : ''}`,
description: `订单状态:${formatStatusLabel(event.fromStatus)}${formatStatusLabel(
event.toStatus,
)}`,
color: 'purple',
}
}
case 'acceptance_updated':
return { title: '补充了验收资料', color: 'purple' }
case 'acceptance_draft_saved':
return { title: '暂存了验收草稿', color: 'purple' }
case 'accepted_by_internal':
return {
title: '内部打手自助验收通过,订单完成',
description: `订单状态:${formatStatusLabel(event.fromStatus)}${formatStatusLabel(
event.toStatus,
)}`,
color: 'purple',
}
case 'accepted':
case 'accepted_by_admin':
return {
title: '验收通过,订单完成',
description: `订单状态:${formatStatusLabel(event.fromStatus)}${formatStatusLabel(
event.toStatus,
)}`,
color: 'purple',
}
// —— 问题节点 ——
case 'marked_problem':
return {
title: note ? `将订单标记为问题单:${note}` : '将订单标记为问题单',
color: 'red',
}
case 'problem_return_to_worker':
return { title: '问题单已处置:退回打手继续处理', color: 'orange' }
case 'problem_reopen':
return { title: '问题单已处置:重新开工', color: 'orange' }
case 'problem_cancel_release':
return { title: '问题单已处置:取消订单并释放押金', color: 'orange' }
case 'problem_cancel_deduct':
return { title: '问题单已处置:取消订单并扣除押金', color: 'orange' }
// —— 取消节点 ——
case 'cancelled_by_admin':
return {
title: note ? `取消订单(${note}` : '取消订单',
description: `订单状态:${formatStatusLabel(event.fromStatus)}${formatStatusLabel(
event.toStatus,
)}`,
color: 'red',
}
// —— 其他 ——
case 'edited':
return { title: '更改了订单信息', color: 'default' }
case 'sharing_config_updated':
return { title: '更新了拼单配置', color: 'default' }
case 'pending_deposit_deducted':
return { title: '扣减了待解冻押金', color: 'default' }
case 'voucher_backfilled':
return { title: '回填了电子凭证信息', color: 'default' }
case 'assign_voucher_consume_failed':
case 'publish_voucher_consume_failed':
return { title: '电子凭证核销失败,本次操作未生效', color: 'red' }
default:
// 通用状态流转兜底
if (event.fromStatus && event.toStatus && event.fromStatus !== event.toStatus) {
return {
title: `订单状态变更:${formatStatusLabel(event.fromStatus)}${formatStatusLabel(
event.toStatus,
)}`,
color: 'default',
}
}
return { title: type, color: 'default' }
}
}
/** 订单详情中的流转节点时间线(管理端 / 打手端共用) */
export default function WorkOrderEventTimeline({
orderId,
viewer,
}: {
orderId: number
viewer: 'admin' | 'worker'
}) {
const eventsQuery = useQuery({
queryKey: ['work-order-events', viewer, orderId],
queryFn: () =>
viewer === 'admin' ? fetchAdminWorkOrderEvents(orderId) : fetchWorkerMyOrderEvents(orderId),
enabled: Boolean(orderId),
retry: false,
})
if (eventsQuery.isLoading) {
return <Skeleton active paragraph={{ rows: 4 }} />
}
if (eventsQuery.error) {
return (
<Typography.Text type="secondary">
{eventsQuery.error instanceof Error ? eventsQuery.error.message : '读取流转记录失败'}
</Typography.Text>
)
}
const events = eventsQuery.data?.data?.items || []
if (events.length === 0) {
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无流转记录" />
}
// 倒序:最新节点在上
const items = [...events].reverse().map((event) => {
const style = formatWorkOrderEvent(event)
return {
color: style.color,
children: (
<div className="work-order-event-item">
<div className="work-order-event-head">
<Typography.Text strong>{style.title}</Typography.Text>
<Tooltip title={dayjs(event.time).format('YYYY-MM-DD HH:mm:ss')}>
<Typography.Text type="secondary" className="work-order-event-time">
<ClockCircleOutlined /> {dayjs(event.time).format('MM-DD HH:mm')}
</Typography.Text>
</Tooltip>
</div>
{style.description ? (
<Typography.Text type="secondary" className="work-order-event-desc">
{style.description}
</Typography.Text>
) : null}
<div className="work-order-event-actor">
<Tag
color={
event.actorType === 'system'
? 'default'
: event.actorType === 'worker'
? 'blue'
: event.actorType === 'admin'
? 'green'
: 'default'
}
>
{event.actorName || event.actorType}
</Tag>
</div>
</div>
),
}
})
return <Timeline items={items} className="work-order-event-timeline" />
}
@@ -40,6 +40,7 @@ import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router'
import JsonPreview from '@/components/admin/JsonPreview'
import WorkOrderEventTimeline from '@/components/WorkOrderEventTimeline'
import ImageUpload from '@/components/files/ImageUpload'
import ImagePreviewList from '@/components/files/ImagePreviewList'
import {
@@ -1101,6 +1102,10 @@ export default function WorkOrdersPanel() {
</div>
</Card>
<Card title="流转记录" size="small">
<WorkOrderEventTimeline orderId={detailOrder.workOrderId} viewer="admin" />
</Card>
<Card title="原始数据" size="small">
<Tabs
className="worker-order-detail-json-tabs"
@@ -30,6 +30,7 @@ import type { TableColumnsType } from 'antd'
import { useState } from 'react'
import { DeadlineCountdown } from '@/components/DeadlineCountdown'
import WorkOrderEventTimeline from '@/components/WorkOrderEventTimeline'
import ImagePreviewList from '@/components/files/ImagePreviewList'
import ImageUpload from '@/components/files/ImageUpload'
import {
@@ -1102,6 +1103,10 @@ export default function WorkerOrdersPage() {
</span>
</Card>
) : null}
<Card title="流转记录" size="small">
<WorkOrderEventTimeline orderId={detailOrder.workOrderId} viewer="worker" />
</Card>
</div>
) : null}
</Drawer>
@@ -3,6 +3,7 @@ import type {
UploadedFile,
WorkCategory,
WorkOrder,
WorkOrderEvent,
WorkOrderShare,
WorkProductRule,
WorkerFinanceConfig,
@@ -200,6 +201,12 @@ export function fetchAdminWorkOrders(params?: Record<string, unknown>) {
return apiGet<WorkerListResponse<WorkOrder>>('/api/v1/admin/worker-platform/orders', params)
}
export function fetchAdminWorkOrderEvents(workOrderId: number) {
return apiGet<{ items: WorkOrderEvent[] }>(
`/api/v1/admin/worker-platform/orders/${workOrderId}/events`,
)
}
export function createAdminMockWorkOrder(payload: {
platformOrderId?: string
productName?: string
+5
View File
@@ -3,6 +3,7 @@ import type {
CollectLookupResponse,
UploadedFile,
WorkOrder,
WorkOrderEvent,
WorkOrderShare,
WorkerFinanceRequest,
WorkerHallOrdersResponse,
@@ -123,6 +124,10 @@ export function fetchWorkerMyOrders(params?: Record<string, unknown>) {
return apiGet<WorkerListResponse<WorkOrder>>('/api/v1/worker/orders', params)
}
export function fetchWorkerMyOrderEvents(workOrderId: number) {
return apiGet<{ items: WorkOrderEvent[] }>(`/api/v1/worker/orders/${workOrderId}/events`)
}
export function saveWorkerOrderNote(workOrderId: number, note: string) {
return apiPost<{ order: WorkOrder }>(`/api/v1/worker/orders/${workOrderId}/note`, { note })
}
+34
View File
@@ -168,6 +168,40 @@
gap: 16px;
}
/* ===== 工单流转记录时间线 ===== */
.work-order-event-timeline {
margin-top: 4px;
}
.work-order-event-item {
display: grid;
gap: 4px;
}
.work-order-event-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.work-order-event-head .ant-typography {
margin: 0;
}
.work-order-event-time {
font-size: 12px;
white-space: nowrap;
}
.work-order-event-actor .ant-tag {
margin-inline-end: 0;
}
.work-order-event-desc {
font-size: 12px;
}
.worker-order-actions {
display: grid;
justify-items: start;
@@ -269,6 +269,18 @@ export type WorkOrder = {
updatedAt: string
}
/** 工单流转事件(订单详情中的操作日志) */
export type WorkOrderEvent = {
id: number
time: string
actorType: string
actorName: string
eventType: string
fromStatus: string
toStatus: string
payload: Record<string, unknown>
}
export type Pagination = {
page: number
pageSize: number