接单平台新增拼单与取消接单能力
拼单功能: - 工单/物品规则支持拼单配置(启用、总数量、单价),总价=单价×数量三方联动,启用拼单时工单金额自动同步拼单总价 - 拼单参与按数量冻结比例押金,验收通过后按份额结算报酬,升级统计按数量计入 - 抢单大厅拼单入口:紫色渐变大按钮带单价展示,拼单进度/剩余份数,弹窗输入数量实时预估报酬与押金 - 我的订单展示拼单份额(数量×单价、状态),拼单者各自提交验收,全部提交后管理员统一验收 - 新增 work_order_shares 表与迁移 008 取消接单: - 进行中订单可取消(押金全额退还、工单回大厅),30 天内限 5 次防滥用
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
ReloadOutlined,
|
||||
SendOutlined,
|
||||
SyncOutlined,
|
||||
TeamOutlined,
|
||||
WarningOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
@@ -44,6 +45,7 @@ import {
|
||||
fetchAdminWorkerFinanceConfig,
|
||||
fetchAdminWorkerFinanceRequests,
|
||||
fetchAdminWorkCategories,
|
||||
fetchAdminWorkOrderSharing,
|
||||
fetchAdminWorkProductRules,
|
||||
fetchAdminWorkerLevels,
|
||||
fetchAdminWorkerPlatformSummary,
|
||||
@@ -61,12 +63,14 @@ import {
|
||||
submitAdminWorkOrderMaterial,
|
||||
syncAdminWorkerOrdersFromSource,
|
||||
unpublishAdminWorkOrder,
|
||||
updateAdminWorkOrderSharing,
|
||||
} from '@/services/admin'
|
||||
import type {
|
||||
CollectField,
|
||||
UploadedFile,
|
||||
WorkCategory,
|
||||
WorkOrder,
|
||||
WorkOrderShare,
|
||||
WorkProductRule,
|
||||
WorkerFinanceConfig,
|
||||
WorkerFinanceRequest,
|
||||
@@ -161,15 +165,84 @@ function WorkOrdersPanel() {
|
||||
const [problemOrder, setProblemOrder] = useState<WorkOrder | null>(null)
|
||||
const [resolutionOrder, setResolutionOrder] = useState<WorkOrder | null>(null)
|
||||
const [materialOrder, setMaterialOrder] = useState<WorkOrder | null>(null)
|
||||
const [sharingOrder, setSharingOrder] = useState<WorkOrder | null>(null)
|
||||
const [problemForm] = Form.useForm()
|
||||
const [resolutionForm] = Form.useForm()
|
||||
const [materialForm] = Form.useForm()
|
||||
const [sharingForm] = Form.useForm<{
|
||||
enabled?: boolean
|
||||
totalQuantity?: number
|
||||
unitReward?: number
|
||||
totalAmount?: number
|
||||
}>()
|
||||
|
||||
const ordersQuery = useQuery({
|
||||
queryKey: ['admin-worker-platform-orders', status, page, pageSize],
|
||||
queryFn: () => fetchAdminWorkOrders({ status, page, pageSize }),
|
||||
})
|
||||
const ordersPagination = ordersQuery.data?.data.pagination
|
||||
const sharingQuery = useQuery({
|
||||
queryKey: ['admin-worker-platform-order-sharing', sharingOrder?.workOrderId],
|
||||
queryFn: () => fetchAdminWorkOrderSharing(Number(sharingOrder?.workOrderId)),
|
||||
enabled: Boolean(sharingOrder),
|
||||
})
|
||||
|
||||
function openSharingConfigModal(row: WorkOrder) {
|
||||
setSharingOrder(row)
|
||||
const unitRewardFen = Number(row.sharing?.unitReward || 0)
|
||||
const totalQuantity = Number(row.sharing?.totalQuantity || 1)
|
||||
const unitRewardYuan = unitRewardFen / 100
|
||||
sharingForm.setFieldsValue({
|
||||
enabled: row.sharing?.enabled,
|
||||
totalQuantity,
|
||||
unitReward: unitRewardYuan,
|
||||
totalAmount: unitRewardYuan * totalQuantity,
|
||||
})
|
||||
}
|
||||
|
||||
function syncSharingForm(next: {
|
||||
totalQuantity?: number
|
||||
unitReward?: number
|
||||
totalAmount?: number
|
||||
}) {
|
||||
const values = sharingForm.getFieldsValue()
|
||||
const unitReward = Number(next.unitReward ?? values.unitReward ?? 0)
|
||||
const totalQuantity = Number(next.totalQuantity ?? values.totalQuantity ?? 1)
|
||||
const totalAmount = Number(next.totalAmount ?? values.totalAmount ?? 0)
|
||||
if (next.totalAmount !== undefined) {
|
||||
const quantity = unitReward > 0 ? Math.max(1, Math.round(totalAmount / unitReward)) : 1
|
||||
sharingForm.setFieldsValue({
|
||||
totalQuantity: quantity,
|
||||
totalAmount: quantity * unitReward,
|
||||
})
|
||||
return
|
||||
}
|
||||
sharingForm.setFieldsValue({
|
||||
totalAmount: unitReward * totalQuantity,
|
||||
})
|
||||
}
|
||||
|
||||
async function submitSharingConfig(values: {
|
||||
enabled?: boolean
|
||||
totalQuantity?: number
|
||||
unitReward?: number
|
||||
totalAmount?: number
|
||||
}) {
|
||||
if (!sharingOrder) return
|
||||
const succeeded = await runAction(
|
||||
() =>
|
||||
updateAdminWorkOrderSharing(sharingOrder.workOrderId, {
|
||||
enabled: values.enabled !== false,
|
||||
totalQuantity: Number(values.totalQuantity || 1),
|
||||
unitReward: Number(values.unitReward || 0),
|
||||
totalAmount: Number(values.totalAmount || 0),
|
||||
}),
|
||||
'拼单配置已保存',
|
||||
)
|
||||
if (!succeeded) return
|
||||
setSharingOrder(null)
|
||||
sharingForm.resetFields()
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
await Promise.all([
|
||||
@@ -299,6 +372,26 @@ function WorkOrdersPanel() {
|
||||
width: 110,
|
||||
render: (_, row) => formatMoney(row.requiredDepositAmount),
|
||||
},
|
||||
{
|
||||
title: '拼单',
|
||||
width: 190,
|
||||
render: (_, row) =>
|
||||
row.sharing?.enabled ? (
|
||||
<div className="cell-stack">
|
||||
<Tag color="purple">
|
||||
单价 {formatMoney(row.sharing.unitReward)} / 共 {row.sharing.totalQuantity} 份
|
||||
</Tag>
|
||||
<Typography.Text type="secondary">
|
||||
已拼 {row.sharingProgress?.joinedQuantity || 0} / {row.sharing.totalQuantity} 份
|
||||
{row.sharingProgress?.pendingSubmissionCount
|
||||
? ` · ${row.sharingProgress.pendingSubmissionCount} 人待提交`
|
||||
: ''}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
) : (
|
||||
<Typography.Text type="secondary">未启用</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
width: 130,
|
||||
@@ -336,6 +429,14 @@ function WorkOrdersPanel() {
|
||||
render: (_, row) => (
|
||||
<Space wrap>
|
||||
<Button onClick={() => openDetailDrawer(row)}>详情</Button>
|
||||
{['pending_material', 'unassigned', 'open'].includes(row.status) ? (
|
||||
<Button
|
||||
icon={<TeamOutlined />}
|
||||
onClick={() => openSharingConfigModal(row)}
|
||||
>
|
||||
拼单配置
|
||||
</Button>
|
||||
) : null}
|
||||
{row.status === 'pending_material' ? (
|
||||
<Button
|
||||
icon={<FormOutlined />}
|
||||
@@ -737,6 +838,138 @@ function WorkOrdersPanel() {
|
||||
))}
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`拼单配置 · ${sharingOrder?.productName || ''}`}
|
||||
open={Boolean(sharingOrder)}
|
||||
onCancel={() => {
|
||||
setSharingOrder(null)
|
||||
sharingForm.resetFields()
|
||||
}}
|
||||
onOk={() => sharingForm.submit()}
|
||||
destroyOnHidden
|
||||
width={720}
|
||||
>
|
||||
{sharingOrder ? (
|
||||
<Space direction="vertical" size={16} className="full-width">
|
||||
<Form
|
||||
form={sharingForm}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
enabled: false,
|
||||
totalQuantity: 1,
|
||||
unitReward: 0,
|
||||
}}
|
||||
onFinish={submitSharingConfig}
|
||||
>
|
||||
<Form.Item
|
||||
label="启用拼单"
|
||||
name="enabled"
|
||||
valuePropName="checked"
|
||||
tooltip="启用后打手可在抢单大厅按单价×数量参与拼单,未拼满时整单仍可被抢走"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{({ getFieldValue }) =>
|
||||
getFieldValue('enabled') ? (
|
||||
<Space size={16} wrap>
|
||||
<Form.Item
|
||||
label="总数量"
|
||||
name="totalQuantity"
|
||||
rules={[{ required: true, message: '请填写总数量' }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={100000}
|
||||
addonAfter="份"
|
||||
onChange={(value) =>
|
||||
syncSharingForm({
|
||||
totalQuantity: Number(value) || 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="单价"
|
||||
name="unitReward"
|
||||
rules={[{ required: true, message: '请填写单价' }]}
|
||||
tooltip="每个数量份对应的报酬,修改后总价自动重算"
|
||||
>
|
||||
<InputNumber
|
||||
min={0.01}
|
||||
step={1}
|
||||
addonAfter="元"
|
||||
onChange={(value) =>
|
||||
syncSharingForm({ unitReward: Number(value) || 0 })
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="拼单总价"
|
||||
name="totalAmount"
|
||||
rules={[{ required: true, message: '请填写总价' }]}
|
||||
tooltip="总价 = 单价 × 数量,修改总价会自动反推数量"
|
||||
>
|
||||
<InputNumber
|
||||
min={0.01}
|
||||
step={1}
|
||||
addonAfter="元"
|
||||
onChange={(value) =>
|
||||
syncSharingForm({ totalAmount: Number(value) || 0 })
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
) : null
|
||||
}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Card size="small" title="拼单明细">
|
||||
{sharingQuery.isLoading ? (
|
||||
<Typography.Text type="secondary">加载中...</Typography.Text>
|
||||
) : (sharingQuery.data?.data.shares || []).length === 0 ? (
|
||||
<Typography.Text type="secondary">暂无打手参与拼单</Typography.Text>
|
||||
) : (
|
||||
<Table<WorkOrderShare>
|
||||
rowKey="shareId"
|
||||
size="small"
|
||||
dataSource={sharingQuery.data?.data.shares || []}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{
|
||||
title: '打手',
|
||||
render: (_, share) =>
|
||||
share.worker?.displayName || share.worker?.username || '-',
|
||||
},
|
||||
{ title: '数量', dataIndex: 'quantity' },
|
||||
{
|
||||
title: '份额报酬',
|
||||
render: (_, share) => formatMoney(share.shareReward),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
render: (_, share) => (
|
||||
<Tag color={resolveSharingShareStatusColor(share.status)}>
|
||||
{formatSharingShareStatus(share.status)}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '提交时间',
|
||||
render: (_, share) =>
|
||||
share.submittedAt
|
||||
? formatAdminDateTime(share.submittedAt)
|
||||
: '-',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</Space>
|
||||
) : null}
|
||||
</Modal>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -828,6 +1061,10 @@ function ProductRulesPanel() {
|
||||
rewardAmount?: number
|
||||
requiredDepositAmount?: number
|
||||
depositThresholdAmount?: number
|
||||
sharingEnabled?: boolean
|
||||
sharingTotalQuantity?: number
|
||||
sharingUnitReward?: number
|
||||
sharingTotalAmount?: number
|
||||
fieldsText?: string
|
||||
enabled?: boolean
|
||||
autoCreate?: boolean
|
||||
@@ -849,6 +1086,30 @@ function ProductRulesPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
function syncSharingRuleForm(next: {
|
||||
sharingTotalQuantity?: number
|
||||
sharingUnitReward?: number
|
||||
sharingTotalAmount?: number
|
||||
}) {
|
||||
const values = form.getFieldsValue()
|
||||
const unitReward = Number(next.sharingUnitReward ?? values.sharingUnitReward ?? 0)
|
||||
const totalQuantity = Number(
|
||||
next.sharingTotalQuantity ?? values.sharingTotalQuantity ?? 1,
|
||||
)
|
||||
const totalAmount = Number(next.sharingTotalAmount ?? values.sharingTotalAmount ?? 0)
|
||||
if (next.sharingTotalAmount !== undefined) {
|
||||
const quantity = unitReward > 0 ? Math.max(1, Math.round(totalAmount / unitReward)) : 1
|
||||
form.setFieldsValue({
|
||||
sharingTotalQuantity: quantity,
|
||||
sharingTotalAmount: quantity * unitReward,
|
||||
})
|
||||
return
|
||||
}
|
||||
form.setFieldsValue({
|
||||
sharingTotalAmount: unitReward * totalQuantity,
|
||||
})
|
||||
}
|
||||
|
||||
const columns: TableColumnsType<WorkProductRule> = [
|
||||
{
|
||||
title: '规则',
|
||||
@@ -893,6 +1154,11 @@ function ProductRulesPanel() {
|
||||
{row.enabled ? '启用' : '停用'}
|
||||
</Tag>
|
||||
{row.autoCreate ? <Tag color="blue">自动创建</Tag> : null}
|
||||
{row.sharing?.enabled ? (
|
||||
<Tag color="purple">
|
||||
拼单 {formatMoney(row.sharing.unitReward)}/份
|
||||
</Tag>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
@@ -912,6 +1178,10 @@ function ProductRulesPanel() {
|
||||
depositThresholdAmount: 200,
|
||||
enabled: true,
|
||||
autoCreate: false,
|
||||
sharingEnabled: false,
|
||||
sharingTotalQuantity: 10,
|
||||
sharingUnitReward: 5,
|
||||
sharingTotalAmount: 50,
|
||||
fieldsText:
|
||||
'gameAccount:游戏账号\nserverName:区服\nroleName:角色名',
|
||||
}}
|
||||
@@ -997,7 +1267,73 @@ function ProductRulesPanel() {
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="启用拼单"
|
||||
name="sharingEnabled"
|
||||
valuePropName="checked"
|
||||
tooltip="开启后按下方总数量与单价生成拼单工单"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{({ getFieldValue }) =>
|
||||
getFieldValue('sharingEnabled') ? (
|
||||
<Space size={16} wrap style={{ marginBottom: 16 }}>
|
||||
<Form.Item
|
||||
label="拼单总数量"
|
||||
name="sharingTotalQuantity"
|
||||
rules={[{ required: true, message: '请填写总数量' }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={100000}
|
||||
addonAfter="份"
|
||||
onChange={(value) =>
|
||||
syncSharingRuleForm({
|
||||
sharingTotalQuantity: Number(value) || 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="拼单单价"
|
||||
name="sharingUnitReward"
|
||||
rules={[{ required: true, message: '请填写单价' }]}
|
||||
tooltip="每个数量份对应的报酬,修改后总价自动重算"
|
||||
>
|
||||
<InputNumber
|
||||
min={0.01}
|
||||
step={1}
|
||||
addonAfter="元"
|
||||
onChange={(value) =>
|
||||
syncSharingRuleForm({
|
||||
sharingUnitReward: Number(value) || 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="拼单总价"
|
||||
name="sharingTotalAmount"
|
||||
rules={[{ required: true, message: '请填写总价' }]}
|
||||
tooltip="总价 = 单价 × 数量,修改总价会自动反推数量"
|
||||
>
|
||||
<InputNumber
|
||||
min={0.01}
|
||||
step={1}
|
||||
addonAfter="元"
|
||||
onChange={(value) =>
|
||||
syncSharingRuleForm({
|
||||
sharingTotalAmount: Number(value) || 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
) : null
|
||||
}
|
||||
</Form.Item>
|
||||
<Form.Item label="资料字段" name="fieldsText">
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
@@ -2423,6 +2759,23 @@ function formatFinanceChannel(channel: string) {
|
||||
return channel || '-'
|
||||
}
|
||||
|
||||
function formatSharingShareStatus(status: string) {
|
||||
const labels: Record<string, string> = {
|
||||
joined: '已参与',
|
||||
submitted: '已提交验收',
|
||||
accepted: '已验收',
|
||||
cancelled: '已取消',
|
||||
}
|
||||
return labels[status] || status
|
||||
}
|
||||
|
||||
function resolveSharingShareStatusColor(status: string) {
|
||||
if (status === 'accepted') return 'green'
|
||||
if (status === 'submitted') return 'gold'
|
||||
if (status === 'cancelled') return 'default'
|
||||
return 'purple'
|
||||
}
|
||||
|
||||
function maskFinanceAccount(value: string) {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) return '-'
|
||||
|
||||
Reference in New Issue
Block a user