add:新增同一个工单同一人可以多次拼单
This commit is contained in:
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user