Files
order_site/apps/frontend/src/components/WorkOrderEventTimeline.tsx
T

313 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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" />
}