完善拼单状态与份额验收流程

This commit is contained in:
yml2213
2026-08-18 18:02:43 +08:00
parent 052b99b05f
commit ee79195f47
17 changed files with 1143 additions and 196 deletions
@@ -424,6 +424,8 @@ function patchAdminWorkOrderCaches(
patchWorkOrderListEnvelope(current, { ...event, operation }, shouldInsert),
)
}
// 状态聚合会影响分页统计,实时行快照不能单独修正全量统计,因此同步刷新查询。
void queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-orders'] })
}
function patchAdminTaskCaches(queryClient: ReturnType<typeof useQueryClient>, tasks: unknown[]) {
+6 -1
View File
@@ -333,7 +333,12 @@ function upsertById(items: unknown[], nextItem: unknown, idKey: string, insert =
(item) => Number((item as Record<string, unknown>)?.[idKey] || 0) === id,
)
if (index < 0) return insert ? [nextItem, ...items] : items
return items.map((item, itemIndex) => (itemIndex === index ? nextItem : item))
return items.map((item, itemIndex) => {
if (itemIndex !== index) return item
if (!isRecord(item) || !isRecord(nextItem)) return nextItem
// 大厅广播是公共快照,合并后保留当前打手私有的 myShare 等字段。
return { ...item, ...nextItem }
})
}
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -45,6 +45,7 @@ import ImageUpload from '@/components/files/ImageUpload'
import ImagePreviewList from '@/components/files/ImagePreviewList'
import {
acceptAdminWorkOrder,
acceptAdminWorkOrderShare,
acceptAdminWorkOrders,
assignAdminWorkOrderToWorker,
cancelAdminWorkOrder,
@@ -741,16 +742,18 @@ export default function WorkOrdersPanel() {
{
title: '状态',
width: 150,
render: (_, row) => (
<Space wrap size={4}>
<Tag color={resolveStatusColor(row.status)}>{formatStatus(row.status)}</Tag>
{row.status === 'open' && row.pinnedAt ? (
<Tag color="gold" icon={<PushpinOutlined />}>
</Tag>
) : null}
</Space>
),
render: (_, row) => {
return (
<Space wrap size={4}>
<Tag color={resolveStatusColor(row.status)}>{formatStatus(row.status)}</Tag>
{row.rawStatus === 'open' && row.pinnedAt ? (
<Tag color="gold" icon={<PushpinOutlined />}>
</Tag>
) : null}
</Space>
)
},
},
{
title: '打手',
@@ -768,6 +771,16 @@ export default function WorkOrdersPanel() {
render: (_, row) => {
const isVipAutoAcceptance = row.acceptance?.reviewMode === 'vip_auto'
const evidencePending = row.acceptance?.evidenceStatus === 'pending'
if (row.sharing?.enabled) {
return (
<Button type="link" size="small" onClick={() => openSharingConfigModal(row)}>
{row.sharingProgress?.pendingSubmissionCount
? `${row.sharingProgress.pendingSubmissionCount}人待提交)`
: ''}
</Button>
)
}
return (
<Space direction="vertical" size={4}>
<ImagePreviewList
@@ -791,37 +804,37 @@ export default function WorkOrdersPanel() {
render: (_, row) => (
<Space wrap>
<Button onClick={() => openDetailDrawer(row)}></Button>
{['pending_material', 'unassigned', 'open'].includes(row.status) ? (
{row.capabilities?.canEdit ? (
<Button icon={<EditOutlined />} onClick={() => openEditModal(row)}>
</Button>
) : null}
{['pending_material', 'unassigned', 'cancelled'].includes(row.status) ? (
{row.capabilities?.canDelete ? (
<Button danger icon={<DeleteOutlined />} onClick={() => requestDeleteWorkOrder(row)}>
</Button>
) : null}
{['pending_material', 'unassigned', 'open'].includes(row.status) ? (
{row.sharing?.enabled || row.capabilities?.canConfigureSharing ? (
<Button icon={<TeamOutlined />} onClick={() => openSharingConfigModal(row)}>
{row.sharing?.enabled && !row.capabilities?.canConfigureSharing ? '拼单明细' : '拼单配置'}
</Button>
) : null}
{['unassigned', 'open'].includes(row.status) ? (
{row.capabilities?.canAssign ? (
<Button icon={<UserAddOutlined />} onClick={() => openAssignModal(row)}>
</Button>
) : null}
{row.status === 'in_progress' && row.worker ? (
{row.capabilities?.canCancel ? (
<Button danger icon={<UndoOutlined />} onClick={() => openCancelModal(row)}>
</Button>
) : null}
{['pending_material', 'in_progress'].includes(row.status) ? (
{row.capabilities?.canUpdateMaterial ? (
<Button icon={<FormOutlined />} onClick={() => openMaterialModal(row)}>
{row.status === 'in_progress' ? '更新资料' : '补资料'}
</Button>
) : null}
{row.status === 'unassigned' ? (
{row.capabilities?.canPublish ? (
<Button
icon={<SendOutlined />}
disabled={Number(row.rewardAmount || 0) <= 0}
@@ -833,7 +846,7 @@ export default function WorkOrdersPanel() {
</Button>
) : null}
{row.status === 'open' ? (
{row.capabilities?.canPin ? (
<Button
icon={<PushpinOutlined />}
title={row.pinnedAt ? '取消置顶' : '置顶后抢单大厅优先展示'}
@@ -847,7 +860,7 @@ export default function WorkOrdersPanel() {
{row.pinnedAt ? '取消置顶' : '置顶'}
</Button>
) : null}
{row.status === 'open' ? (
{row.capabilities?.canUnpublish ? (
<Button
danger
onClick={() => {
@@ -864,7 +877,7 @@ export default function WorkOrdersPanel() {
</Button>
) : null}
{row.status === 'pending_acceptance' ? (
{row.capabilities?.canAccept ? (
<Button
icon={<CheckOutlined />}
type="primary"
@@ -873,17 +886,17 @@ export default function WorkOrdersPanel() {
</Button>
) : null}
{row.status === 'accepted' && Number(row.pendingUnfreezeAmount || 0) > 0 ? (
{row.capabilities?.canDeduct && Number(row.pendingUnfreezeAmount || 0) > 0 ? (
<Button icon={<WarningOutlined />} danger onClick={() => openDeductModal(row)}>
</Button>
) : null}
{['in_progress', 'pending_acceptance'].includes(row.status) ? (
{row.capabilities?.canMarkProblem ? (
<Button icon={<WarningOutlined />} danger onClick={() => setProblemOrder(row)}>
</Button>
) : null}
{row.status === 'problem' ? (
{row.capabilities?.canResolveProblem ? (
<Button icon={<FormOutlined />} onClick={() => setResolutionOrder(row)}>
</Button>
@@ -1572,13 +1585,14 @@ export default function WorkOrdersPanel() {
</Modal>
<Modal
title={`拼单配置 · ${sharingOrder?.productName || ''}`}
title={`${sharingOrder?.capabilities?.canConfigureSharing ? '拼单配置' : '拼单明细'} · ${sharingOrder?.productName || ''}`}
open={Boolean(sharingOrder)}
onCancel={() => {
setSharingOrder(null)
sharingForm.resetFields()
}}
onOk={() => sharingForm.submit()}
footer={sharingOrder?.capabilities?.canConfigureSharing ? undefined : null}
destroyOnHidden
width={720}
>
@@ -1593,6 +1607,7 @@ export default function WorkOrdersPanel() {
unitReward: 0,
}}
onFinish={submitSharingConfig}
disabled={!sharingOrder.capabilities?.canConfigureSharing}
>
<Form.Item
label="启用拼单"
@@ -1689,6 +1704,54 @@ export default function WorkOrdersPanel() {
render: (_, share) =>
share.submittedAt ? formatAdminDateTime(share.submittedAt) : '-',
},
{
title: '验收图',
width: 150,
render: (_, share) => (
<ImagePreviewList
files={Array.isArray(share.acceptance?.files) ? share.acceptance.files : []}
imageUrls={Array.isArray(share.acceptance?.imageUrls) ? share.acceptance.imageUrls : []}
size={44}
maxVisible={2}
/>
),
},
{
title: '备注',
render: (_, share) => String(share.acceptance?.note || '').trim() || '-',
},
{
title: '操作',
width: 110,
render: (_, share) =>
share.status === 'submitted' ? (
<Button
type="primary"
size="small"
onClick={() => {
void runAction(
() => acceptAdminWorkOrderShare(sharingOrder.workOrderId, share.shareId),
'该拼单参与者已验收',
).then((succeeded) => {
if (succeeded) {
void queryClient.invalidateQueries({
queryKey: [
'admin-worker-platform-order-sharing',
sharingOrder.workOrderId,
],
})
}
})
}}
>
</Button>
) : share.status === 'accepted' ? (
<Tag color="green"></Tag>
) : (
<Typography.Text type="secondary"></Typography.Text>
),
},
]}
/>
)}
@@ -1796,8 +1859,7 @@ function ProductNameEditableCell({
const savingRef = useRef(false)
const clickTimerRef = useRef<number | null>(null)
const editable =
['pending_material', 'unassigned', 'open'].includes(order.status) && !order.worker
const editable = order.capabilities?.canEdit === true
function handleClick() {
if (clickTimerRef.current) window.clearTimeout(clickTimerRef.current)
@@ -1880,8 +1942,7 @@ function AmountEditableCell({
const [draft, setDraft] = useState<number | null>(null)
const savingRef = useRef(false)
const editable =
['pending_material', 'unassigned', 'open'].includes(order.status) && !order.worker
const editable = order.capabilities?.canEdit === true
const currentFen = Number(order[field] || 0)
const currentYuan = currentFen / 100
const paymentFen = field === 'rewardAmount' ? getUserPaymentFen(order) : null
@@ -36,6 +36,12 @@ import {
} from '@/services/worker'
import type { WorkOrder, WorkerUser } from '@/types/worker-platform'
import { useIsMobile } from '@/utils/use-is-mobile'
import {
resolveHallDepositAmount,
resolveHallProductName,
resolveHallRemainingQuantity,
resolveHallRewardAmount,
} from '@/utils/worker-product-name'
type HallSummary = {
pendingAcceptanceCount: number
@@ -203,10 +209,7 @@ export default function WorkerHallPage() {
}
function resolveSharingRemaining(order: WorkOrder): number {
const sharing = order.sharing
if (!sharing?.enabled) return 0
const progress = order.sharingProgress
return Math.max(0, sharing.totalQuantity - (progress?.joinedQuantity || 0))
return resolveHallRemainingQuantity(order)
}
function resolveSharingDeposit(order: WorkOrder, quantity: number): number {
@@ -387,6 +390,8 @@ export default function WorkerHallPage() {
) : (
orders.map((order) => {
const disabledReason = resolveGrabDisabledReason(order, worker, remainingSlots)
const productName = resolveHallProductName(order) || '未命名商品'
const sharingRemaining = resolveSharingRemaining(order)
return (
<article key={order.workOrderId} className="worker-hall-mobile-card">
@@ -409,7 +414,8 @@ export default function WorkerHallPage() {
) : null}
{order.sharing?.enabled ? (
<Tag color="purple" className="worker-hall-mobile-tag">
{resolveSharingRemaining(order)}
{order.sharingProgress?.joinedQuantity || 0}/
{order.sharing.totalQuantity} · {resolveSharingRemaining(order)}
</Tag>
) : null}
{Number(order.timeoutMinutes || 0) > 0 ? (
@@ -429,14 +435,11 @@ export default function WorkerHallPage() {
{/* 商品标题与保证金说明 */}
<div className="worker-hall-mobile-card-main">
<strong
className="worker-hall-mobile-card-title"
title={order.productName || '未命名商品'}
>
{order.productName || '未命名商品'}
<strong className="worker-hall-mobile-card-title" title={productName}>
{productName}
</strong>
<div className="worker-hall-mobile-card-meta">
<span>: {formatMoney(order.freezeDepositAmount)}</span>
<span>: {formatMoney(resolveHallDepositAmount(order))}</span>
{order.sharing?.enabled ? (
<span> · {formatMoney(order.sharing.unitReward)}/</span>
) : null}
@@ -448,12 +451,14 @@ export default function WorkerHallPage() {
<div className="worker-hall-mobile-card-price-group">
<span className="worker-hall-mobile-card-price-label"></span>
<strong className="worker-hall-mobile-card-price-val">
{formatMoney(order.rewardAmount)}
{formatMoney(resolveHallRewardAmount(order))}
</strong>
</div>
<div className="worker-hall-mobile-card-btn-group">
{order.sharing?.enabled && !disabledReason ? (
{order.sharing?.enabled &&
(sharingRemaining > 0 || order.myShare) &&
!disabledReason ? (
<Button
type="primary"
size="middle"
@@ -466,16 +471,18 @@ export default function WorkerHallPage() {
: `拼单 ${formatMoney(order.sharing.unitReward)}/份`}
</Button>
) : null}
<Button
type="primary"
size="middle"
className="worker-hall-mobile-grab-btn"
disabled={Boolean(disabledReason)}
loading={grabbingId === order.workOrderId}
onClick={() => submitGrab(order)}
>
{disabledReason || '立即抢单'}
</Button>
{sharingRemaining > 0 || !order.sharing?.enabled ? (
<Button
type="primary"
size="middle"
className="worker-hall-mobile-grab-btn"
disabled={Boolean(disabledReason)}
loading={grabbingId === order.workOrderId}
onClick={() => submitGrab(order)}
>
{disabledReason || '立即抢单'}
</Button>
) : null}
</div>
</div>
</article>
@@ -613,6 +620,8 @@ export default function WorkerHallPage() {
<div className="worker-hall-card-grid">
{orders.map((order) => {
const disabledReason = resolveGrabDisabledReason(order, worker, remainingSlots)
const productName = resolveHallProductName(order) || '未命名商品'
const sharingRemaining = resolveSharingRemaining(order)
return (
<article key={order.workOrderId} className="worker-hall-card">
@@ -626,11 +635,8 @@ export default function WorkerHallPage() {
</Tag>
) : null}
<strong
className="worker-hall-card-title-text"
title={order.productName || '未命名商品'}
>
{order.productName || '未命名商品'}
<strong className="worker-hall-card-title-text" title={productName}>
{productName}
</strong>
<Typography.Text type="secondary">
{order.categoryName || '默认分类'}
@@ -661,10 +667,10 @@ export default function WorkerHallPage() {
<div className="worker-hall-card-amount-row">
<div className="worker-hall-card-amount">
{formatMoney(order.rewardAmount)}
{formatMoney(resolveHallRewardAmount(order))}
</div>
<Typography.Text type="secondary" className="worker-hall-card-deposit">
{formatMoney(order.freezeDepositAmount)}
{formatMoney(resolveHallDepositAmount(order))}
</Typography.Text>
</div>
@@ -676,7 +682,9 @@ export default function WorkerHallPage() {
<div className="worker-hall-card-actions">
<div className="worker-hall-card-grab-row">
{order.sharing?.enabled && !disabledReason ? (
{order.sharing?.enabled &&
(sharingRemaining > 0 || order.myShare) &&
!disabledReason ? (
<Button
type="primary"
size="large"
@@ -689,15 +697,17 @@ export default function WorkerHallPage() {
: `拼单 ${formatMoney(order.sharing.unitReward)}/份`}
</Button>
) : null}
<Button
type="primary"
size="large"
disabled={Boolean(disabledReason)}
loading={grabbingId === order.workOrderId}
onClick={() => submitGrab(order)}
>
{disabledReason || '立即抢单'}
</Button>
{sharingRemaining > 0 || !order.sharing?.enabled ? (
<Button
type="primary"
size="large"
disabled={Boolean(disabledReason)}
loading={grabbingId === order.workOrderId}
onClick={() => submitGrab(order)}
>
{disabledReason || '立即抢单'}
</Button>
) : null}
</div>
</div>
</article>
@@ -713,7 +723,7 @@ export default function WorkerHallPage() {
)}
<Modal
title={`拼单参与 · ${sharingOrder?.productName || ''}`}
title={`拼单参与 · ${sharingOrder ? resolveHallProductName(sharingOrder) : ''}`}
width={isMobile ? '92%' : 520}
open={Boolean(sharingOrder)}
confirmLoading={submittingSharing}
@@ -831,7 +841,7 @@ function resolveGrabDisabledReason(
worker: WorkerUser | undefined,
remainingSlots: number | null,
) {
if (remainingSlots !== null && remainingSlots <= 0) {
if (remainingSlots !== null && remainingSlots <= 0 && !order.myShare) {
return '已达到当前等级最大同时接单量'
}
@@ -840,7 +850,7 @@ function resolveGrabDisabledReason(
}
const availableAmount = Number(worker.wallet.availableAmount || 0)
const freezeAmount = Number(order.freezeDepositAmount || 0)
const freezeAmount = resolveHallDepositAmount(order)
if (availableAmount < freezeAmount) {
return `余额不足,还差 ${formatMoney(freezeAmount - availableAmount)}`
}
@@ -464,6 +464,12 @@ export function acceptAdminWorkOrder(workOrderId: number) {
return apiPost<{ order: WorkOrder }>(`/api/v1/admin/worker-platform/orders/${workOrderId}/accept`)
}
export function acceptAdminWorkOrderShare(workOrderId: number, shareId: number) {
return apiPost<{ order: WorkOrder; share: WorkOrderShare }>(
`/api/v1/admin/worker-platform/orders/${workOrderId}/shares/${shareId}/accept`,
)
}
export function acceptAdminWorkOrders(workOrderIds: number[]) {
return apiPost<{ orders: WorkOrder[] }>('/api/v1/admin/worker-platform/orders/accept-batch', {
workOrderIds,
@@ -353,6 +353,23 @@ export type WorkOrder = {
categoryId?: number | null
categoryName: string
status: WorkOrderStatus
rawStatus?: WorkOrderStatus
hallAvailable?: boolean
capabilities?: {
canEdit: boolean
canConfigureSharing: boolean
canAssign: boolean
canPin: boolean
canUnpublish: boolean
canUpdateMaterial: boolean
canAccept: boolean
canMarkProblem: boolean
canDelete: boolean
canPublish: boolean
canCancel: boolean
canResolveProblem: boolean
canDeduct: boolean
}
rewardAmount: number
requiredDepositAmount?: number
depositThresholdAmount?: number
@@ -362,6 +379,8 @@ export type WorkOrder = {
sharingProgress?: {
joinedQuantity: number
pendingSubmissionCount: number
remainingQuantity?: number
filled?: boolean
}
pendingUnfreezeAmount?: number
timeoutMinutes?: number
@@ -0,0 +1,51 @@
type HallProduct = {
productName?: string
rewardAmount?: number
freezeDepositAmount?: number
sharing?: {
enabled: boolean
totalQuantity: number
unitReward?: number
}
sharingProgress?: {
joinedQuantity: number
}
}
const PRODUCT_QUANTITY_PATTERN = /(\d+|[零一二三四五六七八九十百千两]+)(\s*)(个|份|张|枚)/
/** 计算大厅当前仍可承接的份数。 */
export function resolveHallRemainingQuantity(order: HallProduct): number {
if (!order.sharing?.enabled) return 0
const totalQuantity = Math.max(1, Number(order.sharing.totalQuantity) || 1)
const joinedQuantity = Math.max(0, Number(order.sharingProgress?.joinedQuantity || 0))
return Math.max(0, totalQuantity - joinedQuantity)
}
/** 大厅拼单标题展示剩余可承接数量,避免把原始总规格误认为剩余库存。 */
export function resolveHallProductName(order: HallProduct): string {
const productName = String(order.productName || '').trim()
if (!productName || !order.sharing?.enabled) return productName
const remainingQuantity = resolveHallRemainingQuantity(order)
return productName.replace(
PRODUCT_QUANTITY_PATTERN,
(_matched, _quantity: string, spacing: string, unit: string) =>
`${remainingQuantity}${spacing}${unit}`,
)
}
/** 拼单卡片金额展示一次性抢走全部剩余份数可获得的报酬。 */
export function resolveHallRewardAmount(order: HallProduct): number {
if (!order.sharing?.enabled) return Number(order.rewardAmount || 0)
return resolveHallRemainingQuantity(order) * Number(order.sharing.unitReward || 0)
}
/** 拼单卡片保证金按剩余份数占总份数的比例展示。 */
export function resolveHallDepositAmount(order: HallProduct): number {
if (!order.sharing?.enabled) return Number(order.freezeDepositAmount || 0)
const totalQuantity = Math.max(1, Number(order.sharing.totalQuantity) || 1)
return Math.round(
(Number(order.freezeDepositAmount || 0) * resolveHallRemainingQuantity(order)) / totalQuantity,
)
}
@@ -0,0 +1,69 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
resolveHallDepositAmount,
resolveHallProductName,
resolveHallRemainingQuantity,
resolveHallRewardAmount,
} from '../src/utils/worker-product-name.ts'
test('拼单大厅标题按剩余份数更新阿拉伯数字规格', () => {
assert.equal(
resolveHallProductName({
productName: '指挥官密钥15个',
sharing: { enabled: true, totalQuantity: 15 },
sharingProgress: { joinedQuantity: 3 },
}),
'指挥官密钥12个',
)
})
test('拼单大厅标题兼容中文数字规格', () => {
assert.equal(
resolveHallProductName({
productName: '指挥官密钥十五份',
sharing: { enabled: true, totalQuantity: 15 },
sharingProgress: { joinedQuantity: 4 },
}),
'指挥官密钥11份',
)
})
test('普通单保留原始标题', () => {
assert.equal(
resolveHallProductName({
productName: '指挥官密钥1个',
sharing: { enabled: false, totalQuantity: 1 },
sharingProgress: { joinedQuantity: 0 },
}),
'指挥官密钥1个',
)
})
test('立即抢单的份数、报酬和保证金都按剩余数量计算', () => {
const order = {
productName: '指挥官密钥15个',
rewardAmount: 10_500,
freezeDepositAmount: 1_500,
sharing: { enabled: true, totalQuantity: 15, unitReward: 700 },
sharingProgress: { joinedQuantity: 3 },
}
assert.equal(resolveHallRemainingQuantity(order), 12)
assert.equal(resolveHallRewardAmount(order), 8_400)
assert.equal(resolveHallDepositAmount(order), 1_200)
})
test('拼单份数用完后剩余报酬和保证金归零', () => {
const order = {
rewardAmount: 10_500,
freezeDepositAmount: 1_500,
sharing: { enabled: true, totalQuantity: 15, unitReward: 700 },
sharingProgress: { joinedQuantity: 15 },
}
assert.equal(resolveHallRemainingQuantity(order), 0)
assert.equal(resolveHallRewardAmount(order), 0)
assert.equal(resolveHallDepositAmount(order), 0)
})