接单平台新增拼单与取消接单能力
拼单功能: - 工单/物品规则支持拼单配置(启用、总数量、单价),总价=单价×数量三方联动,启用拼单时工单金额自动同步拼单总价 - 拼单参与按数量冻结比例押金,验收通过后按份额结算报酬,升级统计按数量计入 - 抢单大厅拼单入口:紫色渐变大按钮带单价展示,拼单进度/剩余份数,弹窗输入数量实时预估报酬与押金 - 我的订单展示拼单份额(数量×单价、状态),拼单者各自提交验收,全部提交后管理员统一验收 - 新增 work_order_shares 表与迁移 008 取消接单: - 进行中订单可取消(押金全额退还、工单回大厅),30 天内限 5 次防滥用
This commit is contained in:
@@ -9,6 +9,8 @@ import {
|
||||
Drawer,
|
||||
Empty,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Pagination,
|
||||
Space,
|
||||
Spin,
|
||||
@@ -23,6 +25,7 @@ import {
|
||||
fetchWorkerMyOrders,
|
||||
fetchWorkerProfile,
|
||||
grabWorkerOrder,
|
||||
joinWorkerSharingOrder,
|
||||
} from '@/services/worker'
|
||||
import type { CollectField, WorkOrder, WorkerUser } from '@/types/worker-platform'
|
||||
import { formatDateTime } from '@/utils/date-time'
|
||||
@@ -43,6 +46,9 @@ export default function WorkerHallPage() {
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [grabbingId, setGrabbingId] = useState(0)
|
||||
const [detailOrder, setDetailOrder] = useState<WorkOrder | null>(null)
|
||||
const [sharingOrder, setSharingOrder] = useState<WorkOrder | null>(null)
|
||||
const [sharingQuantity, setSharingQuantity] = useState(1)
|
||||
const [submittingSharing, setSubmittingSharing] = useState(false)
|
||||
|
||||
const ordersQuery = useQuery({
|
||||
queryKey: ['worker-hall-orders', keyword, categoryId, page, pageSize],
|
||||
@@ -135,6 +141,44 @@ export default function WorkerHallPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function openSharingModal(order: WorkOrder) {
|
||||
setSharingOrder(order)
|
||||
setSharingQuantity(1)
|
||||
}
|
||||
|
||||
async function submitSharing() {
|
||||
if (!sharingOrder) return
|
||||
setSubmittingSharing(true)
|
||||
try {
|
||||
await joinWorkerSharingOrder(sharingOrder.workOrderId, {
|
||||
quantity: sharingQuantity,
|
||||
})
|
||||
message.success('拼单成功,已进入我的订单')
|
||||
setSharingOrder(null)
|
||||
setDetailOrder(null)
|
||||
await refreshAll()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '拼单失败')
|
||||
} finally {
|
||||
setSubmittingSharing(false)
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
function resolveSharingDeposit(order: WorkOrder, quantity: number): number {
|
||||
const sharing = order.sharing
|
||||
if (!sharing?.enabled) return 0
|
||||
const deposit = Number(order.requiredDepositAmount || 0)
|
||||
const total = Math.max(1, sharing.totalQuantity)
|
||||
return Math.round((deposit * quantity) / total)
|
||||
}
|
||||
|
||||
function applyKeywordSearch() {
|
||||
setKeyword(keywordInput.trim())
|
||||
setPage(1)
|
||||
@@ -293,6 +337,18 @@ export default function WorkerHallPage() {
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
{order.sharing?.enabled ? (
|
||||
<div className="worker-hall-card-sharing">
|
||||
<Tag color="purple">拼单</Tag>
|
||||
<Typography.Text type="secondary">
|
||||
单价 {formatMoney(order.sharing.unitReward)} · 进度{' '}
|
||||
{order.sharingProgress?.joinedQuantity || 0}/
|
||||
{order.sharing.totalQuantity} · 剩{' '}
|
||||
{resolveSharingRemaining(order)} 份
|
||||
</Typography.Text>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="worker-hall-card-amount-row">
|
||||
<div className="worker-hall-card-amount">
|
||||
{formatMoney(order.rewardAmount)}
|
||||
@@ -315,17 +371,40 @@ export default function WorkerHallPage() {
|
||||
) : null}
|
||||
|
||||
<div className="worker-hall-card-actions">
|
||||
<Button icon={<EyeOutlined />} onClick={() => setDetailOrder(order)}>
|
||||
详情
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={Boolean(disabledReason)}
|
||||
loading={grabbingId === order.workOrderId}
|
||||
onClick={() => submitGrab(order)}
|
||||
>
|
||||
立即抢单
|
||||
</Button>
|
||||
<div className="worker-hall-card-actions-row">
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => setDetailOrder(order)}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
</div>
|
||||
<div className="worker-hall-card-grab-row">
|
||||
{order.sharing?.enabled ? (
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
className="worker-hall-sharing-button"
|
||||
disabled={Boolean(disabledReason) || Boolean(order.myShare)}
|
||||
onClick={() => openSharingModal(order)}
|
||||
>
|
||||
{order.myShare
|
||||
? `已拼 ${order.myShare.quantity} 份`
|
||||
: `拼单 ${formatMoney(order.sharing.unitReward)}/份`}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
disabled={Boolean(disabledReason)}
|
||||
loading={grabbingId === order.workOrderId}
|
||||
onClick={() => submitGrab(order)}
|
||||
>
|
||||
{disabledReason || '立即抢单'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
@@ -424,6 +503,48 @@ export default function WorkerHallPage() {
|
||||
</div>
|
||||
) : null}
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
title={`拼单参与 · ${sharingOrder?.productName || ''}`}
|
||||
open={Boolean(sharingOrder)}
|
||||
confirmLoading={submittingSharing}
|
||||
onCancel={() => setSharingOrder(null)}
|
||||
onOk={submitSharing}
|
||||
okText="确认拼单"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ disabled: sharingQuantity <= 0 }}
|
||||
>
|
||||
{sharingOrder ? (
|
||||
<Space direction="vertical" size={16} className="full-width">
|
||||
<Alert
|
||||
showIcon
|
||||
type="info"
|
||||
message={`单价 ${formatMoney(sharingOrder.sharing?.unitReward || 0)} / 份,剩余 ${resolveSharingRemaining(sharingOrder)} 份可拼`}
|
||||
/>
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="拼单进度">
|
||||
{sharingOrder.sharingProgress?.joinedQuantity || 0} /{' '}
|
||||
{sharingOrder.sharing?.totalQuantity || 0} 份
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="参与数量">
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={resolveSharingRemaining(sharingOrder)}
|
||||
value={sharingQuantity}
|
||||
onChange={(value) => setSharingQuantity(Number(value) || 1)}
|
||||
addonAfter="份"
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="预估报酬">
|
||||
{formatMoney((sharingOrder.sharing?.unitReward || 0) * sharingQuantity)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="预估冻结押金">
|
||||
{formatMoney(resolveSharingDeposit(sharingOrder, sharingQuantity))}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Space>
|
||||
) : null}
|
||||
</Modal>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,7 +29,11 @@ import { useState } from 'react'
|
||||
|
||||
import ImagePreviewList from '@/components/files/ImagePreviewList'
|
||||
import ImageUpload from '@/components/files/ImageUpload'
|
||||
import { fetchWorkerMyOrders, submitWorkerAcceptance } from '@/services/worker'
|
||||
import {
|
||||
cancelWorkerOrder,
|
||||
fetchWorkerMyOrders,
|
||||
submitWorkerAcceptance,
|
||||
} from '@/services/worker'
|
||||
import type { CollectField, UploadedFile, WorkOrder } from '@/types/worker-platform'
|
||||
import { formatDateTime } from '@/utils/date-time'
|
||||
|
||||
@@ -63,7 +67,7 @@ type DetailFieldItem = {
|
||||
}
|
||||
|
||||
export default function WorkerOrdersPage() {
|
||||
const { message } = App.useApp()
|
||||
const { message, modal } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const [status, setStatus] = useState('')
|
||||
const [keywordInput, setKeywordInput] = useState('')
|
||||
@@ -141,6 +145,26 @@ export default function WorkerOrdersPage() {
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
function openCancelModal(order: WorkOrder) {
|
||||
modal.confirm({
|
||||
title: '确认取消接单?',
|
||||
content:
|
||||
'取消后订单将重新回到抢单大厅,冻结押金全额退还。30 天内取消次数有限,请谨慎操作。',
|
||||
okText: '确认取消',
|
||||
cancelText: '再想想',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
try {
|
||||
await cancelWorkerOrder(order.workOrderId)
|
||||
message.success('已取消接单,押金已退还')
|
||||
await refreshAll()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '取消接单失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const columns: TableColumnsType<WorkOrder> = [
|
||||
{
|
||||
title: '订单信息',
|
||||
@@ -177,6 +201,26 @@ export default function WorkerOrdersPage() {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '我的拼单',
|
||||
width: 170,
|
||||
render: (_, row) =>
|
||||
row.myShare ? (
|
||||
<div className="cell-stack">
|
||||
<Tag color="purple">
|
||||
{formatSharingShareStatus(row.myShare.status)}
|
||||
</Tag>
|
||||
<Typography.Text>
|
||||
{row.myShare.quantity} 份 × {formatMoney(row.myShare.unitReward)}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
份额报酬:{formatMoney(row.myShare.shareReward)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
) : (
|
||||
<Typography.Text type="secondary">-</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '当前状态',
|
||||
width: 180,
|
||||
@@ -221,7 +265,7 @@ export default function WorkerOrdersPage() {
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 200,
|
||||
width: 280,
|
||||
render: (_, row) => (
|
||||
<Space wrap>
|
||||
<Button icon={<EyeOutlined />} onClick={() => setDetailOrder(row)}>
|
||||
@@ -236,6 +280,14 @@ export default function WorkerOrdersPage() {
|
||||
提交验收
|
||||
</Button>
|
||||
) : null}
|
||||
{canCancelOrder(row) ? (
|
||||
<Button
|
||||
danger
|
||||
onClick={() => openCancelModal(row)}
|
||||
>
|
||||
取消接单
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
@@ -380,6 +432,11 @@ export default function WorkerOrdersPage() {
|
||||
<Tag color={resolveStatusColor(detailOrder.status)}>
|
||||
{formatStatus(detailOrder.status)}
|
||||
</Tag>
|
||||
{canCancelOrder(detailOrder) ? (
|
||||
<Button danger onClick={() => openCancelModal(detailOrder)}>
|
||||
取消接单
|
||||
</Button>
|
||||
) : null}
|
||||
{canSubmitAcceptance(detailOrder) ? (
|
||||
<Button
|
||||
type="primary"
|
||||
@@ -523,9 +580,16 @@ export default function WorkerOrdersPage() {
|
||||
}
|
||||
|
||||
function canSubmitAcceptance(order: WorkOrder) {
|
||||
if (order.myShare) {
|
||||
return order.myShare.status === 'joined' && order.status !== 'accepted'
|
||||
}
|
||||
return ['in_progress', 'problem'].includes(order.status)
|
||||
}
|
||||
|
||||
function canCancelOrder(order: WorkOrder) {
|
||||
return !order.myShare && order.status === 'in_progress'
|
||||
}
|
||||
|
||||
function getRequirementFields(order: WorkOrder | null): CollectField[] {
|
||||
if (!order) return []
|
||||
const rawFields = Array.isArray(order.requirement?.fields)
|
||||
@@ -688,3 +752,13 @@ function resolveStatusColor(status: string) {
|
||||
if (status === 'pending_acceptance') return 'gold'
|
||||
return 'blue'
|
||||
}
|
||||
|
||||
function formatSharingShareStatus(status: string) {
|
||||
const labels: Record<string, string> = {
|
||||
joined: '已参与',
|
||||
submitted: '已提交验收',
|
||||
accepted: '已验收',
|
||||
cancelled: '已取消',
|
||||
}
|
||||
return labels[status] || status
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user