add:新增同一个工单同一人可以多次拼单

This commit is contained in:
2026-10-23 15:14:07 +08:00
parent bfd2325ac5
commit 1d5615f1ae
18 changed files with 2996 additions and 128 deletions
@@ -0,0 +1,213 @@
import { Button, Space, Table, Tag, Typography } from 'antd'
import type { TableColumnsType } from 'antd'
import { DeadlineCountdown } from '@/components/DeadlineCountdown'
import ImagePreviewList from '@/components/files/ImagePreviewList'
import type { WorkOrder, WorkOrderShare } from '@/types/worker-platform'
import { formatDateTime } from '@/utils/date-time'
import { formatMoney, getOrderShares, getUniqueAcceptanceFiles } from './worker-order-utils'
const SHARE_STATUS_META: Record<string, { label: string; color: string }> = {
joined: { label: '进行中', color: 'processing' },
submitted: { label: '待验收', color: 'orange' },
accepted: { label: '已验收', color: 'green' },
cancelled: { label: '已取消', color: 'default' },
}
/**
* 一个工单一行,展开后展示该打手在这个工单里的每一份份额。
* 数量 / 奖励 / 押金直接取份额字段,与后台「拼单明细」同口径。
*/
export function WorkerOrderShareTable({
order,
mobile = false,
onOpenAcceptance,
onOpenCancelRequest,
onRemindAcceptance,
}: {
order: WorkOrder
mobile?: boolean
onOpenAcceptance: (order: WorkOrder, shareId: number) => void
onOpenCancelRequest: (order: WorkOrder, shareId: number) => void
onRemindAcceptance: (order: WorkOrder) => void
}) {
const shares = getOrderShares(order)
.slice()
.sort((left, right) => {
const byCreatedAt = String(left.createdAt || '').localeCompare(String(right.createdAt || ''))
return byCreatedAt !== 0 ? byCreatedAt : left.shareId - right.shareId
})
const isTimedOut = order.status === 'open' || Boolean(order.timeoutNotice?.isTimedOut)
if (shares.length === 0) return null
const columns: TableColumnsType<WorkOrderShare> = [
{
title: '份额',
width: 92,
render: (_, share) => (
<div className="cell-stack">
<Typography.Text strong>
第 {shares.findIndex((item) => item.shareId === share.shareId) + 1} 份
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
#{share.shareId}
</Typography.Text>
</div>
),
},
{
title: '数量',
width: 80,
render: (_, share) => <Typography.Text strong>{share.quantity} 份</Typography.Text>,
},
{
title: '奖励',
width: 110,
render: (_, share) => (
<Typography.Text strong style={{ color: '#cf1322' }}>
{formatMoney(share.shareReward)}
</Typography.Text>
),
},
{
title: '押金',
width: 100,
render: (_, share) => formatMoney(share.shareDeposit),
},
{
title: '状态',
width: 96,
render: (_, share) => {
const meta = SHARE_STATUS_META[String(share.status)] || {
label: String(share.status || '-'),
color: 'default',
}
return <Tag color={meta.color}>{meta.label}</Tag>
},
},
{
title: '截止时间',
width: 170,
render: (_, share) => {
if (!share.deadlineAt) {
return (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
不限时
</Typography.Text>
)
}
return (
<div className="cell-stack">
<Typography.Text style={{ fontSize: 12 }}>
{formatDateTime(share.deadlineAt)}
</Typography.Text>
{share.status === 'joined' && !isTimedOut ? (
<DeadlineCountdown deadlineAt={share.deadlineAt} />
) : null}
</div>
)
},
},
{
title: '验收信息',
width: 220,
render: (_, share) => <ShareAcceptanceCell share={share} />,
},
{
title: '操作',
width: 190,
render: (_, share) => {
const canAccept = !isTimedOut && share.status === 'joined'
const canEdit = !isTimedOut && ['joined', 'submitted'].includes(String(share.status))
const canRemind = !isTimedOut && share.status === 'submitted'
const canCancel = !isTimedOut && ['joined', 'submitted'].includes(String(share.status))
if (!canAccept && !canEdit && !canRemind && !canCancel) {
return (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
无
</Typography.Text>
)
}
return (
<Space size={6} wrap>
{canAccept ? (
<Button
type="primary"
size="small"
onClick={() => onOpenAcceptance(order, share.shareId)}
>
验收
</Button>
) : null}
{canEdit && share.status === 'submitted' ? (
<Button size="small" onClick={() => onOpenAcceptance(order, share.shareId)}>
补充图片
</Button>
) : null}
{canRemind ? (
<Button size="small" onClick={() => onRemindAcceptance(order)}>
催验收
</Button>
) : null}
{canCancel ? (
<Button danger size="small" onClick={() => onOpenCancelRequest(order, share.shareId)}>
申请取消
</Button>
) : null}
</Space>
)
},
},
]
return (
<Table<WorkOrderShare>
rowKey="shareId"
size="small"
bordered={false}
pagination={false}
dataSource={shares}
columns={columns}
className="worker-order-share-table"
scroll={{ x: mobile ? 820 : 1080 }}
/>
)
}
function ShareAcceptanceCell({ share }: { share: WorkOrderShare }) {
const acceptanceFiles = getUniqueAcceptanceFiles(share.acceptance)
const draftFiles = getUniqueAcceptanceFiles(share.draftAcceptance)
if (acceptanceFiles.length === 0 && draftFiles.length === 0 && !share.submittedAt) {
return (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
未提交
</Typography.Text>
)
}
return (
<div className="cell-stack">
{acceptanceFiles.length > 0 ? (
<ImagePreviewList files={acceptanceFiles} size={32} maxVisible={2} />
) : null}
{share.submittedAt ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
提交:{formatDateTime(share.submittedAt)}
</Typography.Text>
) : null}
{draftFiles.length > 0 && !share.submittedAt ? (
<Tag color="processing">已暂存 {draftFiles.length} 张</Tag>
) : null}
{String(share.acceptance?.note || '').trim() ? (
<Typography.Text
type="secondary"
ellipsis={{ tooltip: share.acceptance.note }}
style={{ fontSize: 12 }}
>
{share.acceptance.note}
</Typography.Text>
) : null}
</div>
)
}
@@ -1,4 +1,4 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router'
import { App, Badge, Button, Form, Modal, Radio, Space, Tag, Typography } from 'antd'
import { useState } from 'react'
@@ -193,6 +193,8 @@ export default function WorkerOrdersPage() {
queryKey: ['worker-my-orders', status, keyword, page, pageSize],
queryFn: () => fetchWorkerMyOrders(buildWorkerOrderQuery(status, keyword, page, pageSize)),
staleTime: 2_000,
// 切页签时保留上一页数据,避免请求返回前先闪一下空列表。
placeholderData: keepPreviousData,
refetchOnWindowFocus: false,
retry: false,
})
+8
View File
@@ -0,0 +1,8 @@
/** 客户端幂等键:一次用户点击一个,用于拼单参与/抢单的服务端去重。 */
export function createRequestId(prefix = 'req'): string {
const random =
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
return `${prefix}-${random}`
}
@@ -89,7 +89,20 @@ export function resolveWorkerMyOrderRealtimeVerdict(input: {
}): { operation: 'upsert' | 'remove'; shouldInsert: boolean } {
const item = isRecord(input.item) ? input.item : {}
const statusFilter = String(input.statusFilter || '')
const statusMatches = !statusFilter || statusFilter === String(item.status || '')
/**
* 服务端快照里的 `workerStatuses` 才是「这张单属于哪些页签」的准确答案。
*
* 同一打手在同一工单可能有多份份额(多次拼单),一张单可以同时出现在「代练中」和「待验收」;
* 而快照的 `status` 只能是一个值。旧逻辑拿单个 status 跟页签比对,只要不相等就删行,
* 于是多份额工单总会在其中一个页签里被删掉(表现为「待验收有时候没有数据」)。
* 老事件/其它来源没有该字段时,退回原来的单状态比对。
*/
const tabs = Array.isArray(item.workerStatuses)
? item.workerStatuses.map((tab) => String(tab || '')).filter(Boolean)
: []
const statusMatches =
!statusFilter ||
(tabs.length > 0 ? tabs.includes(statusFilter) : statusFilter === String(item.status || ''))
const statusProvable = input.explicitOrderNo || statusMatches
const shouldRemove = String(input.operation || '') === 'remove' || !statusProvable