313 lines
11 KiB
TypeScript
313 lines
11 KiB
TypeScript
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_by_vip':
|
||
return {
|
||
title: 'VIP 免审核验收通过,订单完成',
|
||
description: readPayload(event, 'evidenceDueAt') ? '可在 24 小时内补充验收图片' : undefined,
|
||
color: 'purple',
|
||
}
|
||
case 'acceptance_evidence_supplemented': {
|
||
const addedImageCount = readPayload(event, 'addedImageCount')
|
||
return {
|
||
title: `已验收后补充验收图片${addedImageCount ? `(新增 ${addedImageCount} 张)` : ''}`,
|
||
color: 'purple',
|
||
}
|
||
}
|
||
case 'sharing_acceptance_submitted': {
|
||
const quantity = readPayload(event, 'quantity')
|
||
return {
|
||
title: `提交拼单份额验收${quantity ? `(${quantity} 份)` : ''}`,
|
||
color: 'purple',
|
||
}
|
||
}
|
||
case 'sharing_share_accepted': {
|
||
const workerId = readPayload(event, 'workerId')
|
||
return {
|
||
title: `拼单份额验收通过并已结算${workerId ? `(打手 #${workerId})` : ''}`,
|
||
color: 'purple',
|
||
}
|
||
}
|
||
case 'sharing_acceptance_evidence_supplemented': {
|
||
const addedImageCount = readPayload(event, 'addedImageCount')
|
||
return {
|
||
title: `拼单份额已验收后补充图片${addedImageCount ? `(新增 ${addedImageCount} 张)` : ''}`,
|
||
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 'reopened_from_cancelled':
|
||
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 'sharing_share_cancelled_by_admin': {
|
||
const workerId = readPayload(event, 'workerId')
|
||
const quantity = readPayload(event, 'quantity')
|
||
return {
|
||
title: `撤销拼单参与者${workerId ? `(打手 #${workerId})` : ''}`,
|
||
description: `${quantity ? `撤销 ${quantity} 份;` : ''}${note ? `原因:${note}` : '份数已退回接单大厅'}`,
|
||
color: 'red',
|
||
}
|
||
}
|
||
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" />
|
||
}
|