接单工单新增流转记录(订单详情时间线)
- 后端:新增工单事件查询接口(管理端 + 打手端),事件带操作人显示名(管理员用户名 / 打手昵称 / 系统) - 补齐关键节点事件:抢单接手(grabbed)、验收通过(accepted 带操作人) - 管理端/打手端事件写入操作人(admin session username 贯通至事件) - 前端:订单详情新增「流转记录」时间线,按订单生命周期节点展示(创建/发单/接手/资料/验收/问题/取消),时间+操作人+状态流转
This commit is contained in:
@@ -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" />
|
||||
}
|
||||
Reference in New Issue
Block a user