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

466 lines
18 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, LinkOutlined } from '@ant-design/icons'
import { useQuery } from '@tanstack/react-query'
import { Empty, Skeleton, Space, Tag, Timeline, Tooltip, Typography } from 'antd'
import { useNavigate } from 'react-router'
import dayjs from '@/lib/dayjs'
import { fetchAdminWorkOrderEvents } from '@/services/admin'
import { fetchWorkerMyOrderEvents } from '@/services/worker'
import type { WorkOrderEvent } from '@/types/worker-platform'
import { getAdminRole } from '@/utils/admin-auth'
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()
}
function readPayloadAmount(event: WorkOrderEvent, key: string): number {
return Number(readPayload(event, key) || 0)
}
function formatFenAmount(value: number) {
return `${(value / 100).toFixed(2)} 元`
}
/** 事件涉及的打手名(管理端接口会附带打手摘要;打手端退回 #id) */
function resolveEventWorkerLabel(event: WorkOrderEvent): string {
if (event.worker?.displayName) return event.worker.displayName
const workerId = readPayload(event, 'workerId')
return workerId ? `打手 #${workerId}` : ''
}
/** 事件类型 → 节点阶段文案与颜色 */
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 workerLabel = resolveEventWorkerLabel(event)
return {
title: workerLabel ? `指派打手接手此单(${workerLabel}` : '指派打手接手此单',
description: `订单状态:${formatStatusLabel(event.fromStatus)}${formatStatusLabel(
event.toStatus,
)}`,
color: 'green',
}
}
case 'unassigned_by_admin':
return { title: '取消指派,订单回到未分配状态', color: 'green' }
case 'returned_to_hall_by_admin': {
const releaseAmount = readPayloadAmount(event, 'releaseAmount')
const forfeitedAmount = readPayloadAmount(event, 'forfeitedAmount')
return {
title: note ? `撤回订单到抢单大厅(${note}` : '撤回订单到抢单大厅',
description:
releaseAmount > 0
? `已退回该打手冻结押金 ${formatFenAmount(releaseAmount)}`
: forfeitedAmount > 0
? `已扣除该打手冻结押金 ${formatFenAmount(forfeitedAmount)}(好友赠送单已确认加好友,押金不退)`
: undefined,
color: 'green',
}
}
case 'cancelled_by_worker':
return { title: '打手取消接单', color: 'red' }
case 'cancel_request_submitted':
return {
title: note ? `打手提交退单申请:${note}` : '打手提交退单申请,等待客服审核',
color: 'orange',
}
case 'cancel_request_rejected':
return {
title: note ? `客服驳回打手退单申请:${note}` : '客服驳回打手退单申请',
color: 'green',
}
case 'cancel_request_approved':
return {
title: note ? `客服同意打手退单申请:${note}` : '客服同意打手退单申请,订单已回到大厅',
color: 'red',
}
case 'worker_feedback_submitted':
return {
title: note ? `打手提交问题反馈:${note}` : '打手提交问题反馈,等待客服处理',
color: 'orange',
}
case 'worker_feedback_resolved':
return {
title: note ? `客服已处理问题反馈:${note}` : '客服已处理问题反馈',
color: 'green',
}
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 workerLabel = resolveEventWorkerLabel(event)
return {
title: `拼单份额验收通过并已结算${workerLabel ? `${workerLabel}` : ''}`,
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 'after_sales_case_created': {
const caseNo = readPayload(event, 'caseNo')
return {
title: `已发起验收后售后问题单${caseNo ? `${caseNo}` : ''}`,
color: 'red',
}
}
case 'after_sales_case_responded':
return { title: '打手已提交售后说明,等待客服处理', color: 'orange' }
case 'after_sales_case_resolved': {
const action = readPayload(event, 'action')
const recoveryAmount = Number(readPayload(event, 'recoveryAmount') || 0)
if (action === 'upheld') {
return {
title: `售后问题成立,追缴 ${recoveryAmount > 0 ? `${(recoveryAmount / 100).toFixed(2)} 元` : '0 元'}`,
color: 'red',
}
}
return {
title: action === 'remedied' ? '售后已补救完成' : '售后问题不成立,已关闭',
color: 'green',
}
}
// —— 取消节点 ——
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': {
const productName = readPayload(event, 'productName')
const rewardAmount = readPayloadAmount(event, 'rewardAmount')
const requiredDepositAmount = readPayloadAmount(event, 'requiredDepositAmount')
const timeoutMinutes = readPayloadAmount(event, 'timeoutMinutes')
const changes = [
productName ? `品名:${productName}` : '',
rewardAmount > 0 ? `报酬 ${formatFenAmount(rewardAmount)}` : '',
requiredDepositAmount > 0 ? `押金 ${formatFenAmount(requiredDepositAmount)}` : '',
timeoutMinutes > 0 ? `时限 ${Math.round(timeoutMinutes)} 分钟` : '',
].filter(Boolean)
return {
title: '更改了订单信息',
description: changes.length > 0 ? `改为:${changes.join(' · ')}` : undefined,
color: 'default',
}
}
case 'sharing_config_updated':
return { title: '更新了拼单配置', color: 'default' }
case 'price_synced_from_rule': {
const rewardAmount = Number(readPayload(event, 'rewardAmount') || 0)
const unitReward = Number(readPayload(event, 'sharingUnitReward') || 0)
return {
title: unitReward > 0 ? '从接单模板同步了拼单剩余份额价格' : '从接单模板同步了接单价格',
description:
rewardAmount > 0 ? `同步后预计总报酬:${(rewardAmount / 100).toFixed(2)} 元` : undefined,
color: 'default',
}
}
case 'sharing_share_cancelled_by_admin': {
const workerLabel = resolveEventWorkerLabel(event)
const quantity = readPayload(event, 'quantity')
return {
title: `撤销拼单参与者${workerLabel ? `${workerLabel}` : ''}`,
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 navigate = useNavigate()
const canNavigateToWorker = viewer === 'admin' && getAdminRole() !== 'support'
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">
{event.worker && event.actorType === 'worker' ? (
<Space size={[4, 4]} wrap>
<Tag
color="blue"
className={canNavigateToWorker ? 'work-order-event-worker-link' : undefined}
onClick={() => {
if (!canNavigateToWorker) return
void navigate(
`/admin/worker-platform?tab=workers&workerId=${event.worker?.workerId}`,
)
}}
>
操作人 · {event.worker.displayName} #{event.worker.workerId}
{canNavigateToWorker ? <LinkOutlined /> : null}
</Tag>
<Typography.Text type="secondary" className="work-order-event-worker-meta">
{event.worker.username} ·{' '}
{event.worker.workerType === 'internal' ? '内部' : '外部'} ·{' '}
{event.worker.levelName} · {formatWorkerStatus(event.worker.status)}
</Typography.Text>
</Space>
) : (
<Space size={[4, 4]} wrap>
<Tag color={resolveActorTagColor(event.actorType)}>
操作人 · {event.actorName || event.actorType || '未知'}
</Tag>
{event.worker ? (
<Space size={[4, 4]} wrap>
<Typography.Text type="secondary" className="work-order-event-worker-meta">
涉及打手
</Typography.Text>
<Tag
color="blue"
className={canNavigateToWorker ? 'work-order-event-worker-link' : undefined}
onClick={() => {
if (!canNavigateToWorker) return
void navigate(
`/admin/worker-platform?tab=workers&workerId=${event.worker?.workerId}`,
)
}}
>
{event.worker.displayName} #{event.worker.workerId}
{canNavigateToWorker ? <LinkOutlined /> : null}
</Tag>
</Space>
) : null}
</Space>
)}
</div>
</div>
),
}
})
return <Timeline items={items} className="work-order-event-timeline" />
}
function formatWorkerStatus(status: string) {
const labels: Record<string, string> = {
active: '已启用',
pending_review: '待审核',
rejected: '已冻结',
disabled: '已停用',
}
return labels[status] || status || '未知状态'
}
function resolveActorTagColor(actorType: string) {
if (actorType === 'admin') return 'green'
if (actorType === 'worker') return 'blue'
if (actorType === 'system') return 'default'
return 'default'
}