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

- 后端:新增工单事件查询接口(管理端 + 打手端),事件带操作人显示名(管理员用户名 / 打手昵称 / 系统)
- 补齐关键节点事件:抢单接手(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" />
}