feat(consignment): 寄卖配置、内部匹配与打手端升级

This commit is contained in:
2026-11-05 15:39:40 +08:00
parent f94b914ab1
commit 2ef16ddcc7
34 changed files with 3705 additions and 350 deletions
@@ -16,6 +16,7 @@ import AnnouncementPanel from './panels/AnnouncementPanel'
import CancelRequestsPanel from './panels/CancelRequestsPanel'
import CategoriesPanel from './panels/CategoriesPanel'
import ConsignmentContactsPanel from './panels/ConsignmentContactsPanel'
import ConsignmentConfigPanel from './panels/ConsignmentConfigPanel'
import ConsignmentInventoryPanel from './panels/ConsignmentInventoryPanel'
import ConsignmentSubmissionsPanel from './panels/ConsignmentSubmissionsPanel'
import FeedbacksPanel from './panels/FeedbacksPanel'
@@ -185,6 +186,11 @@ export default function AdminWorkerPlatformPage() {
: []),
{ key: 'rules', label: '接单模板', children: <ProductRulesPanel /> },
{ key: 'order-service', label: '订单服务', children: <OrderServicePanel /> },
{
key: 'consignment-config',
label: '寄卖配置',
children: <ConsignmentConfigPanel />,
},
{ key: 'product-match', label: '匹配诊断', children: <ProductMatchPanel /> },
{ key: 'categories', label: '分类', children: <CategoriesPanel /> },
{ key: 'workers', label: '打手', children: <WorkersPanel /> },
@@ -0,0 +1,568 @@
import { ReloadOutlined, SaveOutlined, SendOutlined, StopOutlined } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
App,
Button,
Card,
Divider,
Empty,
Form,
Input,
InputNumber,
Select,
Space,
Switch,
Tag,
Typography,
} from 'antd'
import { useEffect, useMemo, useState } from 'react'
import {
archiveAdminConsignmentProfile,
fetchAdminConsignmentProfiles,
publishAdminConsignmentProfile,
saveAdminConsignmentProfile,
} from '@/services/admin/consignment'
import type { ConsignmentProfile } from '@/types/consignment'
const { Text, Title } = Typography
type ConfigFormValues = {
name: string
title: string
description: string
tabTitle: string
fields: {
system: boolean
serverZone: boolean
series: boolean
diamondLevel: boolean
brillianceCount: boolean
quantity: boolean
}
pricing: { useTemplatePrice: boolean; allowWorkerPrice: boolean; minPriceYuan: number }
deposit: {
enabled: boolean
mode: 'fixed' | 'per_quantity'
fixedAmountYuan: number
unitAmountYuan: number
}
serviceFee: { enabled: boolean; mode: 'percent' | 'fixed'; percent: number; amountYuan: number }
inventoryLimit: { enabled: boolean; maxActiveQuantity: number }
matching: {
mode: 'manual' | 'auto'
autoMatchEnabled: boolean
exactOnly: boolean
preferLowReward: boolean
}
internalMatch: { delayMinutes: number; fallbackToHall: boolean }
timeout: { minutes: number; policy: 'reopen' | 'cancel_release' | 'cancel_deduct' }
priceDrop: { enabled: boolean; hours: number; mode: 'percent' | 'fixed'; value: number }
}
function yuan(fen: number | undefined): number {
return Number(((Number(fen || 0) || 0) / 100).toFixed(2))
}
function fen(value: number | undefined): number {
return Math.max(0, Math.round(Number(value || 0) * 100))
}
function toFormValues(profile: ConsignmentProfile): ConfigFormValues {
const config = profile.config
return {
name: profile.name,
title: profile.title,
description: profile.description,
tabTitle: profile.tabTitle,
fields: { ...config.fields },
pricing: {
useTemplatePrice: config.pricing.useTemplatePrice,
allowWorkerPrice: config.pricing.allowWorkerPrice,
minPriceYuan: yuan(config.pricing.minPriceFen),
},
deposit: {
enabled: config.deposit.enabled,
mode: config.deposit.mode,
fixedAmountYuan: yuan(config.deposit.fixedAmountFen),
unitAmountYuan: yuan(config.deposit.unitAmountFen),
},
serviceFee: {
enabled: config.serviceFee.enabled,
mode: config.serviceFee.mode,
percent: config.serviceFee.percent,
amountYuan: yuan(config.serviceFee.amountFen),
},
inventoryLimit: { ...config.inventoryLimit },
matching: { ...config.matching },
internalMatch: { ...config.internalMatch },
timeout: { ...config.timeout },
priceDrop: { ...config.priceDrop },
}
}
function emptyFormValues(): ConfigFormValues {
return {
name: '寄卖配置',
title: '',
description: '',
tabTitle: '',
fields: {
system: true,
serverZone: true,
series: true,
diamondLevel: true,
brillianceCount: true,
quantity: true,
},
pricing: { useTemplatePrice: true, allowWorkerPrice: false, minPriceYuan: 0 },
deposit: { enabled: false, mode: 'fixed', fixedAmountYuan: 0, unitAmountYuan: 0 },
serviceFee: { enabled: false, mode: 'percent', percent: 0, amountYuan: 0 },
inventoryLimit: { enabled: false, maxActiveQuantity: 0 },
matching: {
mode: 'manual',
autoMatchEnabled: false,
exactOnly: true,
preferLowReward: true,
},
internalMatch: { delayMinutes: 30, fallbackToHall: true },
timeout: { minutes: 30, policy: 'reopen' },
priceDrop: { enabled: false, hours: 72, mode: 'percent', value: 10 },
}
}
function buildPayload(categoryId: number, values: ConfigFormValues) {
return {
categoryId,
name: values.name,
title: values.title,
description: values.description,
tabTitle: values.tabTitle,
config: {
version: 1,
tabTitle: values.tabTitle,
tabDescription: values.description,
fields: values.fields,
pricing: {
useTemplatePrice: values.pricing.useTemplatePrice,
allowWorkerPrice: values.pricing.allowWorkerPrice,
minPriceFen: fen(values.pricing.minPriceYuan),
},
deposit: {
enabled: values.deposit.enabled,
mode: values.deposit.mode,
fixedAmountFen: fen(values.deposit.fixedAmountYuan),
unitAmountFen: fen(values.deposit.unitAmountYuan),
},
serviceFee: {
enabled: values.serviceFee.enabled,
mode: values.serviceFee.mode,
percent: values.serviceFee.percent,
amountFen: fen(values.serviceFee.amountYuan),
},
inventoryLimit: values.inventoryLimit,
matching: values.matching,
internalMatch: values.internalMatch,
timeout: values.timeout,
priceDrop: values.priceDrop,
},
}
}
export default function ConsignmentConfigPanel() {
const { message } = App.useApp()
const queryClient = useQueryClient()
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null)
const [saving, setSaving] = useState(false)
const [form] = Form.useForm<ConfigFormValues>()
const profilesQuery = useQuery({
queryKey: ['admin-consignment-profiles'],
queryFn: () => fetchAdminConsignmentProfiles(),
})
const data = profilesQuery.data?.data
const categories = useMemo(
() => (data?.categories || []).filter((category) => category.status === 'active'),
[data?.categories],
)
const profiles = data?.profiles || []
const profileByCategoryId = useMemo(
() => new Map(profiles.map((profile) => [profile.categoryId, profile])),
[profiles],
)
const selectedCategory = categories.find((category) => category.categoryId === selectedCategoryId)
const selectedProfile = selectedCategoryId
? profileByCategoryId.get(selectedCategoryId)
: undefined
useEffect(() => {
if (selectedCategoryId === null && categories.length > 0) {
setSelectedCategoryId(categories[0]!.categoryId)
}
}, [categories, selectedCategoryId])
useEffect(() => {
if (!selectedCategoryId) return
if (selectedProfile) {
form.setFieldsValue(toFormValues(selectedProfile))
} else {
form.setFieldsValue({
...emptyFormValues(),
name: selectedCategory?.name ? `${selectedCategory.name}寄卖` : '寄卖配置',
tabTitle: selectedCategory?.name || '',
})
}
}, [selectedCategoryId, selectedProfile, selectedCategory?.name, form])
async function refresh() {
await queryClient.invalidateQueries({ queryKey: ['admin-consignment-profiles'] })
}
async function handleSave() {
if (!selectedCategoryId) return
const values = await form.validateFields()
setSaving(true)
try {
await saveAdminConsignmentProfile(buildPayload(selectedCategoryId, values))
message.success('寄卖配置已保存')
await refresh()
} catch (error) {
message.error(error instanceof Error ? error.message : '保存失败')
} finally {
setSaving(false)
}
}
const publishMutation = useMutation({
mutationFn: (profileId: number) => publishAdminConsignmentProfile(profileId),
onSuccess: async () => {
message.success('已发布新版本,打手端将按新配置展示')
await refresh()
},
onError: (error) => message.error(error instanceof Error ? error.message : '发布失败'),
})
const archiveMutation = useMutation({
mutationFn: (profileId: number) => archiveAdminConsignmentProfile(profileId),
onSuccess: async () => {
message.success('寄卖配置已归档')
await refresh()
},
onError: (error) => message.error(error instanceof Error ? error.message : '归档失败'),
})
return (
<Card
title={
<Space size={8}>
<span>寄卖配置</span>
<Tag color="blue">{profiles.filter((p) => p.status === 'published').length} 个已发布</Tag>
</Space>
}
extra={
<Button
icon={<ReloadOutlined />}
loading={profilesQuery.isFetching}
onClick={() => void refresh()}
>
刷新
</Button>
}
>
<div className="order-service-layout">
<aside className="order-service-sidebar">
<div className="order-service-sidebar-head">
<Text strong>寄卖分类</Text>
</div>
<div className="order-service-category-list">
{categories.map((category) => {
const rowProfile = profileByCategoryId.get(category.categoryId)
return (
<button
key={category.categoryId}
type="button"
className={`order-service-category-item${
category.categoryId === selectedCategoryId ? ' is-active' : ''
}`}
onClick={() => setSelectedCategoryId(category.categoryId)}
>
<span className="order-service-category-main">
<span className="order-service-category-name">
{category.parentId ? ' └ ' : ''}
{category.name}
</span>
</span>
<span className="order-service-category-meta">
<span
className={`order-service-status-pill is-${rowProfile?.status || 'draft'}`}
>
{rowProfile?.status === 'published'
? `已发布 v${rowProfile.currentVersionNo || 1}`
: rowProfile?.status === 'archived'
? '已归档'
: '未配置'}
</span>
</span>
</button>
)
})}
{categories.length === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无启用分类" />
) : null}
</div>
</aside>
<section className="order-service-editor">
{selectedCategory ? (
<>
<div className="order-service-editor-head">
<div>
<Space size={8} wrap>
<Title level={5}>{selectedCategory.name}</Title>
<Tag color={selectedProfile?.status === 'published' ? 'green' : 'orange'}>
{selectedProfile?.status === 'published'
? `线上 v${selectedProfile.currentVersionNo || 1}`
: selectedProfile?.status === 'archived'
? '已归档'
: '草稿'}
</Tag>
</Space>
<Text type="secondary">分类标识:{selectedCategory.categoryKey}</Text>
</div>
<Space size={8} wrap>
<Button
icon={<SaveOutlined />}
loading={saving}
onClick={() => void handleSave()}
>
保存草稿
</Button>
<Button
type="primary"
icon={<SendOutlined />}
disabled={!selectedProfile}
loading={publishMutation.isPending}
onClick={() => {
if (selectedProfile) publishMutation.mutate(selectedProfile.profileId)
}}
>
发布新版本
</Button>
{selectedProfile ? (
<Button
danger
icon={<StopOutlined />}
loading={archiveMutation.isPending}
onClick={() => archiveMutation.mutate(selectedProfile.profileId)}
>
归档
</Button>
) : null}
</Space>
</div>
<Form form={form} layout="vertical">
<Divider plain>基础与打手端展示</Divider>
<Form.Item label="配置名称" name="name" rules={[{ required: true }]}>
<Input placeholder="后台识别名称" />
</Form.Item>
<Form.Item label="打手端 tab 名称" name="tabTitle">
<Input placeholder="留空则使用配置名称" />
</Form.Item>
<Form.Item label="打手端说明" name="description">
<Input.TextArea autoSize={{ minRows: 1, maxRows: 3 }} />
</Form.Item>
<Divider plain>发布字段</Divider>
<Space wrap size={24}>
<Form.Item label="操作系统" name={['fields', 'system']} valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label="区服" name={['fields', 'serverZone']} valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label="系列" name={['fields', 'series']} valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item
label="钻石等级"
name={['fields', 'diamondLevel']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label="璀璨数"
name={['fields', 'brillianceCount']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item label="数量" name={['fields', 'quantity']} valuePropName="checked">
<Switch />
</Form.Item>
</Space>
<Divider plain>价格 / 保证金 / 服务费</Divider>
<Space wrap size={24}>
<Form.Item
label="使用接单模板价"
name={['pricing', 'useTemplatePrice']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label="允许打手改价"
name={['pricing', 'allowWorkerPrice']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item label="最低售价(元)" name={['pricing', 'minPriceYuan']}>
<InputNumber min={0} precision={2} />
</Form.Item>
</Space>
<Space wrap size={24}>
<Form.Item
label="开启保证金"
name={['deposit', 'enabled']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item label="保证金模式" name={['deposit', 'mode']}>
<Select
style={{ width: 140 }}
options={[
{ value: 'fixed', label: '固定金额' },
{ value: 'per_quantity', label: '按数量' },
]}
/>
</Form.Item>
<Form.Item label="固定金额(元)" name={['deposit', 'fixedAmountYuan']}>
<InputNumber min={0} precision={2} />
</Form.Item>
<Form.Item label="每件金额(元)" name={['deposit', 'unitAmountYuan']}>
<InputNumber min={0} precision={2} />
</Form.Item>
</Space>
<Space wrap size={24}>
<Form.Item
label="开启服务费"
name={['serviceFee', 'enabled']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item label="服务费模式" name={['serviceFee', 'mode']}>
<Select
style={{ width: 140 }}
options={[
{ value: 'percent', label: '按比例 %' },
{ value: 'fixed', label: '固定金额' },
]}
/>
</Form.Item>
<Form.Item label="比例(%)" name={['serviceFee', 'percent']}>
<InputNumber min={0} max={100} precision={2} />
</Form.Item>
<Form.Item label="固定金额(元)" name={['serviceFee', 'amountYuan']}>
<InputNumber min={0} precision={2} />
</Form.Item>
</Space>
<Divider plain>数量限制 / 匹配 / 超时 / 降价</Divider>
<Space wrap size={24}>
<Form.Item
label="开启单人寄售上限"
name={['inventoryLimit', 'enabled']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item label="最多在售数量" name={['inventoryLimit', 'maxActiveQuantity']}>
<InputNumber min={0} precision={0} />
</Form.Item>
<Form.Item label="匹配模式" name={['matching', 'mode']}>
<Select
style={{ width: 140 }}
options={[
{ value: 'manual', label: '手动匹配' },
{ value: 'auto', label: '自动匹配' },
]}
/>
</Form.Item>
<Form.Item
label="自动匹配开关"
name={['matching', 'autoMatchEnabled']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label="低价订单优先"
name={['matching', 'preferLowReward']}
valuePropName="checked"
>
<Switch />
</Form.Item>
</Space>
<Space wrap size={24}>
<Form.Item label="内部匹配等待(分钟)" name={['internalMatch', 'delayMinutes']}>
<InputNumber min={0} precision={0} />
</Form.Item>
<Form.Item
label="超时发回大厅"
name={['internalMatch', 'fallbackToHall']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item label="代练超时(分钟)" name={['timeout', 'minutes']}>
<InputNumber min={0} precision={0} />
</Form.Item>
<Form.Item label="超时动作" name={['timeout', 'policy']}>
<Select
style={{ width: 160 }}
options={[
{ value: 'reopen', label: '退回大厅' },
{ value: 'cancel_release', label: '取消并释放' },
{ value: 'cancel_deduct', label: '取消并扣押金' },
]}
/>
</Form.Item>
</Space>
<Space wrap size={24}>
<Form.Item
label="开启降价提示"
name={['priceDrop', 'enabled']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item label="未售出时长(小时)" name={['priceDrop', 'hours']}>
<InputNumber min={1} precision={0} />
</Form.Item>
<Form.Item label="降幅模式" name={['priceDrop', 'mode']}>
<Select
style={{ width: 140 }}
options={[
{ value: 'percent', label: '按比例 %' },
{ value: 'fixed', label: '固定金额' },
]}
/>
</Form.Item>
<Form.Item label="降幅数值" name={['priceDrop', 'value']}>
<InputNumber min={0} precision={0} />
</Form.Item>
</Space>
</Form>
</>
) : (
<Empty description="请选择分类" />
)}
</section>
</div>
</Card>
)
}
@@ -266,6 +266,10 @@ export default function ConsignmentInventoryPanel() {
diamondLevel: Number(values.diamondLevel || 1),
brillianceCount: Number(values.brillianceCount || 0),
costPriceFen,
salePriceFen:
values.salePrice !== undefined
? Math.max(0, Math.round(Number(values.salePrice) * 100))
: undefined,
buyerName: values.buyerName,
shopName: values.shopName,
inboundBatch: values.inboundBatch,
@@ -663,6 +667,7 @@ export default function ConsignmentInventoryPanel() {
editForm.setFieldsValue({
...record,
costPrice: record.costPriceFen / 100,
salePrice: (record.salePriceFen ?? record.costPriceFen) / 100,
workerId: record.workerId || undefined,
// 没有押金就留空,不能兜底成 100:保存会把"无押金"悄悄写成 ¥100
depositAmount: record.depositAmountFen
@@ -915,6 +920,11 @@ export default function ConsignmentInventoryPanel() {
<InputNumber min={0} step={1} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="salePrice" label="寄卖售价 (元)">
<InputNumber min={0} step={1} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="allowMatching" label="允许匹配" valuePropName="checked">
<Switch checkedChildren="开" unCheckedChildren="关" />
@@ -102,6 +102,7 @@ import {
resolveConsignmentScreenshotUrls,
} from '@/utils/consignment-display'
import ConsignmentManualMatchModal from '@/components/admin/ConsignmentManualMatchModal'
import ConsignmentWorkOrderMatchModal from '@/components/admin/ConsignmentWorkOrderMatchModal'
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
import dayjs, { ADMIN_DATE_FORMAT } from '@/lib/dayjs'
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
@@ -161,6 +162,7 @@ const ADMIN_NOTE_QUICK_TAGS = ['已通知加好友'] as const
const FILTERABLE_WORK_ORDER_STATUSES = [
'pending_material',
'unassigned',
'internal_matching',
'open',
'in_progress',
'pending_acceptance',
@@ -413,6 +415,7 @@ export default function WorkOrdersPanel() {
// 寄售库存匹配拦截弹窗状态
const [manualMatchOrder, setManualMatchOrder] = useState<WorkOrder | null>(null)
const [consignmentMatchOrder, setConsignmentMatchOrder] = useState<WorkOrder | null>(null)
const [consignmentInterceptModal, setConsignmentInterceptModal] = useState<{
open: boolean
order: WorkOrder | null
@@ -1988,6 +1991,16 @@ export default function WorkOrdersPanel() {
手动匹配寄售
</Button>
) : null}
{canFulfillConsignment &&
['internal_matching', 'unassigned', 'open'].includes(String(row.status || '')) ? (
<Button
icon={<ThunderboltOutlined />}
onClick={() => setConsignmentMatchOrder(row)}
title="按寄卖配置精确匹配库存;拼单工单可选多条库存一起拼"
>
内部库存匹配
</Button>
) : null}
{row.capabilities?.canCancel && (canReturnToHall || canCancelOrder) ? (
<Button danger icon={<UndoOutlined />} onClick={() => openCancelModal(row)}>
撤单
@@ -4351,6 +4364,17 @@ export default function WorkOrdersPanel() {
</div>
</Modal>
<ConsignmentWorkOrderMatchModal
open={Boolean(consignmentMatchOrder)}
workOrderId={consignmentMatchOrder?.workOrderId ?? null}
onClose={() => setConsignmentMatchOrder(null)}
onMatched={() => {
void queryClient.invalidateQueries({ queryKey: ['admin-work-orders'] })
void queryClient.invalidateQueries({ queryKey: ['admin-work-orders-stats'] })
void queryClient.invalidateQueries({ queryKey: ['admin-consignment-inventories'] })
}}
/>
<ConsignmentManualMatchModal
open={Boolean(manualMatchOrder)}
onClose={() => setManualMatchOrder(null)}
@@ -208,6 +208,7 @@ export function formatStatus(status: string) {
const labels: Record<string, string> = {
pending_material: '待完善',
unassigned: '待分配',
internal_matching: '内部匹配中',
open: '待抢单',
in_progress: '代练中',
pending_acceptance: '待验收',
@@ -223,6 +224,7 @@ export function resolveStatusColor(status: string) {
const colors: Record<string, string> = {
pending_material: 'orange',
unassigned: 'cyan',
internal_matching: 'magenta',
open: 'blue',
in_progress: 'geekblue',
pending_acceptance: 'purple',