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
@@ -22,6 +22,7 @@ type EventNodeStyle = {
const STATUS_LABELS: Record<string, string> = {
pending_material: '待完善资料',
unassigned: '待分配',
internal_matching: '内部匹配中',
open: '待抢单',
in_progress: '代练中',
pending_acceptance: '待验收',
@@ -0,0 +1,161 @@
import { App, Alert, Button, Card, Empty, Modal, Space, Tag, Typography } from 'antd'
import { useQuery } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
import PercentTable from '@/components/common/PercentTable'
import {
fetchAdminConsignmentMatchCandidates,
matchAdminConsignmentWorkOrder,
} from '@/services/admin/consignment'
import type { ConsignmentMatchCandidate } from '@/types/consignment'
function money(fen: number) {
return `¥${((Number(fen || 0) || 0) / 100).toFixed(2)}`
}
export default function ConsignmentWorkOrderMatchModal({
open,
workOrderId,
onClose,
onMatched,
}: {
open: boolean
workOrderId: number | null
onClose: () => void
onMatched?: () => void
}) {
const { message } = App.useApp()
const [selectedIds, setSelectedIds] = useState<number[]>([])
const [submitting, setSubmitting] = useState(false)
const candidatesQuery = useQuery({
queryKey: ['admin-consignment-match-candidates', workOrderId],
queryFn: () => fetchAdminConsignmentMatchCandidates(Number(workOrderId)),
enabled: open && Boolean(workOrderId),
})
const data = candidatesQuery.data?.data
const options = data?.options || []
const needed = Math.max(1, Number(data?.totalQuantity || 1) - Number(data?.activeQuantity || 0))
useEffect(() => {
if (!open) {
setSelectedIds([])
return
}
if (options.length === 0) return
// 默认按缺口选中价格最低的几条,客服可自行调整。
setSelectedIds(options.slice(0, needed).map((item) => item.inventoryId))
}, [open, options, needed])
const totalPrice = useMemo(
() =>
selectedIds.reduce((sum, id) => {
const item = options.find((option) => option.inventoryId === id)
return sum + Number(item?.salePriceFen || 0)
}, 0),
[options, selectedIds],
)
return (
<Modal
title="寄卖库存匹配"
open={open}
onCancel={onClose}
width={860}
destroyOnHidden
footer={
<Space>
<Button onClick={onClose}>取消</Button>
<Button
type="primary"
loading={submitting}
disabled={selectedIds.length === 0}
onClick={async () => {
if (!workOrderId) return
setSubmitting(true)
try {
const result = await matchAdminConsignmentWorkOrder({
workOrderId,
inventoryIds: selectedIds,
})
message.success(
result.data?.matched
? `已匹配 ${result.data.matchedCount || selectedIds.length} 条寄卖库存`
: '未匹配到可用库存',
)
onMatched?.()
onClose()
} catch (error) {
message.error(error instanceof Error ? error.message : '匹配失败')
} finally {
setSubmitting(false)
}
}}
>
确认匹配
</Button>
</Space>
}
>
{candidatesQuery.isLoading ? <Card loading /> : null}
{!candidatesQuery.isLoading && options.length === 0 ? (
<Empty description="没有精确匹配的在库寄卖库存" />
) : null}
{options.length > 0 ? (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message={`该工单${data?.sharingEnabled ? `拼单 ${data.totalQuantity} 份,已匹配 ${data.activeQuantity} 份` : '为整单'},本次还需 ${needed} 条库存。`}
description="自动匹配只做精确规格;手动匹配可选多条,拼单工单每选一条生成一个份额。"
/>
<PercentTable<ConsignmentMatchCandidate>
rowKey="inventoryId"
size="small"
pagination={false}
dataSource={options}
rowSelection={{
selectedRowKeys: selectedIds,
onChange: (keys) => setSelectedIds(keys.map((key) => Number(key))),
}}
columns={[
{
title: '寄卖库存',
render: (_, item) => (
<Space direction="vertical" size={0}>
<Typography.Text strong>{item.itemName}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{item.inventoryNo} · {item.workerName || `打手#${item.workerId || '-'}`}
</Typography.Text>
</Space>
),
},
{
title: '规格',
render: (_, item) =>
`${item.system}/${item.serverZone}/${item.series}/${item.diamondLevel}钻/${item.brillianceCount}璀`,
},
{
title: '库存',
width: 90,
render: (_, item) => (
<Tag color={item.remainingQuantity > 1 ? 'blue' : 'default'}>
剩 {item.remainingQuantity}/{item.quantity}
</Tag>
),
},
{
title: '售价',
width: 110,
render: (_, item) => money(item.salePriceFen),
},
]}
/>
<Typography.Text type="secondary">
已选 {selectedIds.length} 条,合计成本 {money(totalPrice)}
</Typography.Text>
</Space>
) : null}
</Modal>
)
}
@@ -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',
@@ -46,12 +46,15 @@ import {
confirmWorkerConsignmentFriendAdded,
fetchWorkerConsignmentContacts,
fetchWorkerConsignmentPriceSuggestion,
fetchWorkerConsignmentProfiles,
fetchWorkerConsignments,
fetchWorkerProfile,
publishWorkerConsignment,
submitWorkerConsignmentGiftEvidence,
updateWorkerConsignmentPrice,
} from '@/services/worker'
import type { WorkerConsignmentRecord } from '@/services/worker'
import type { WorkerConsignmentProfile } from '@/types/consignment'
import type { UploadedFile } from '@/types/worker-platform'
import {
SURNAN_DEFAULT_ZONE,
@@ -129,6 +132,8 @@ export default function WorkerConsignmentPage() {
const isMobile = useIsMobile()
const { message, modal } = App.useApp()
const [activeTab, setActiveTabState] = useState<'publish' | 'records'>(getInitialActiveTab)
/** 当前选中的寄卖配置(每个已发布配置一个 tab) */
const [selectedProfileId, setSelectedProfileId] = useState<number | null>(null)
const [recordFilter, setRecordFilterState] = useState<RecordFilter>(getInitialRecordFilter)
/** 搜索框里的原始输入(立刻回显),防抖后才作为服务端查询条件 */
const [recordKeywordInput, setRecordKeywordInput] = useState('')
@@ -202,6 +207,23 @@ export default function WorkerConsignmentPage() {
}),
})
// 已发布的分类寄卖配置 → 打手端动态 tab
const consignmentProfilesQuery = useQuery({
queryKey: ['worker-consignment-profiles'],
queryFn: () => fetchWorkerConsignmentProfiles(),
staleTime: 60_000,
})
const consignmentProfiles = consignmentProfilesQuery.data?.data.profiles || []
const selectedProfile: WorkerConsignmentProfile | null =
consignmentProfiles.find((profile) => profile.profileId === selectedProfileId) ||
consignmentProfiles[0] ||
null
useEffect(() => {
if (selectedProfileId === null && consignmentProfiles.length > 0) {
setSelectedProfileId(consignmentProfiles[0]!.profileId)
}
}, [consignmentProfiles, selectedProfileId])
// 发布寄卖必填「对接客服」:只取启用名单,默认选第一个
const consignmentContactsQuery = useQuery({
queryKey: ['worker-consignment-contacts'],
@@ -228,12 +250,21 @@ export default function WorkerConsignmentPage() {
active: 0,
finished: 0,
}
const publishDepositFen = Number(
myConsignmentsQuery.data?.data.publishDepositFen ??
financeConfig?.consignmentPublishDepositFen ??
2000,
)
const watchedQuantity = Form.useWatch('quantity', form)
const quantityForDeposit = Math.max(1, Number(watchedQuantity || 1))
const publishDepositFen = selectedProfile?.config.deposit.enabled
? selectedProfile.config.deposit.mode === 'per_quantity'
? Math.max(0, selectedProfile.config.deposit.unitAmountFen) * quantityForDeposit
: Math.max(0, selectedProfile.config.deposit.fixedAmountFen)
: Number(
myConsignmentsQuery.data?.data.publishDepositFen ??
financeConfig?.consignmentPublishDepositFen ??
2000,
)
const publishDepositYuan = publishDepositFen / 100
const manualPriceAllowed =
selectedProfile?.config.pricing.allowWorkerPrice === true ||
(Boolean(selectedProfile) && !selectedProfile!.config.pricing.useTemplatePrice)
const availableAmount = Number(worker?.wallet?.availableAmount || 0)
const canPublish = availableAmount >= publishDepositFen
const canConsign = canWorkerPublishConsignment({
@@ -403,19 +434,28 @@ export default function WorkerConsignmentPage() {
return
}
// 寄售价由后台接单模板决定,卖家不用填;没匹配到模板价就不让发,避免产出没有定价依据的单
if (priceState.status === 'loading') {
message.warning('正在匹配接单模板价,请稍候')
return
}
if (priceState.status !== 'matched') {
message.error('该规格暂无接单模板价,请联系客服补充模板后再提报')
return
}
const askingPriceFen = priceState.fen
if (askingPriceFen < 100) {
message.error('寄售价格不能少于1元')
return
// 寄售价:配置允许改价/不使用模板价时按打手填写;否则必须匹配到接单模板价。
const minPriceFen = Math.max(0, selectedProfile?.config.pricing.minPriceFen || 0)
let askingPriceFen = priceState.status === 'matched' ? priceState.fen : 0
if (manualPriceAllowed) {
askingPriceFen = Math.max(0, Math.round(Number(values.askingPrice || 0) * 100))
if (askingPriceFen < Math.max(100, minPriceFen)) {
message.error(`寄售价格不能少于 ¥${(Math.max(100, minPriceFen) / 100).toFixed(2)}`)
return
}
} else {
if (priceState.status === 'loading') {
message.warning('正在匹配接单模板价,请稍候')
return
}
if (priceState.status !== 'matched') {
message.error('该规格暂无接单模板价,请联系客服补充模板后再提报')
return
}
if (askingPriceFen < 100) {
message.error('寄售价格不能少于1元')
return
}
}
const briNum = Number(values.brillianceCount || 0)
@@ -427,6 +467,8 @@ export default function WorkerConsignmentPage() {
setSubmitting(true)
try {
await publishWorkerConsignment({
profileId: selectedProfile?.profileId,
quantity: Math.max(1, Number(values.quantity || 1)),
itemName,
system: values.system,
serverZone: values.serverZone,
@@ -550,6 +592,347 @@ export default function WorkerConsignmentPage() {
const recordKeywordTrimmed = recordKeywordInput.trim()
const publishTabContent = profileQuery.isLoading ? (
<div style={{ padding: '48px 16px', textAlign: 'center' }}>
<Spin />
</div>
) : !canConsign ? (
<div style={{ padding: '36px 16px', textAlign: 'center' }}>
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<div style={{ maxWidth: 420, margin: '0 auto' }}>
<Typography.Title level={5} style={{ marginBottom: 8 }}>
寄卖功能暂未开通
</Typography.Title>
<Typography.Paragraph type="secondary" style={{ fontSize: 13, marginBottom: 20 }}>
平台当前开启寄卖白名单。请联系客服申请开通,由后台运营开通后即可发布苏尔南寄卖。
</Typography.Paragraph>
<Button
type="primary"
icon={<CustomerServiceOutlined />}
onClick={() => setContactOpen(true)}
>
联系客服开通寄卖
</Button>
</div>
}
/>
</div>
) : (
<Form
form={form}
layout="vertical"
className="worker-consignment-form"
onFinish={handlePublishSubmit}
onValuesChange={handleFormValuesChange}
>
<Alert
type={canPublish ? 'info' : 'warning'}
showIcon
icon={<SafetyCertificateOutlined />}
className="worker-consignment-tip"
message={
canPublish
? `履约押金 ¥${publishDepositYuan.toFixed(0)}:发布成功后暂扣,驳回或履约完成后释放。当前可用余额 ¥${(availableAmount / 100).toFixed(2)}`
: `可用余额不足 ¥${publishDepositYuan.toFixed(0)}。请先联系客服充值履约押金后,才能发布寄售。当前可用 ¥${(availableAmount / 100).toFixed(2)}`
}
/>
{!consignmentContactsQuery.isLoading && contactSupporters.length === 0 ? (
<Alert
type="warning"
showIcon
message="暂无可选的对接客服,请联系管理员在「配置 - 对接客服」中添加后再发布"
/>
) : null}
<Divider plain className="worker-consignment-divider">
规格与价格
</Divider>
<div className="worker-consignment-specs">
<div className="worker-consignment-spec-grid">
<Form.Item
name="system"
label="操作系统"
rules={[{ required: true, message: '请选择系统' }]}
className="worker-consignment-spec-card"
>
<Radio.Group
className="worker-consignment-chips"
optionType="button"
buttonStyle="solid"
options={[...SURNAN_PUBLISH_SYSTEM_OPTIONS]}
/>
</Form.Item>
<Form.Item
name="serverZone"
label="游戏区服"
rules={[{ required: true, message: '请选择区服' }]}
// 苏尔南区服固定「通用」(QQ/微信互通),唯一选项默认选中
initialValue={SURNAN_DEFAULT_ZONE}
className="worker-consignment-spec-card"
>
<Radio.Group
className="worker-consignment-chips"
optionType="button"
buttonStyle="solid"
options={[...SURNAN_PUBLISH_ZONE_OPTIONS]}
/>
</Form.Item>
<Form.Item
name="series"
label="系列 / 枪系"
rules={[{ required: true, message: '请选择枪系' }]}
className="worker-consignment-spec-card"
>
<Radio.Group
className="worker-consignment-chips"
optionType="button"
buttonStyle="solid"
options={SERIES_OPTIONS}
/>
</Form.Item>
<Form.Item
name="diamondLevel"
label="钻石等级"
rules={[{ required: true, message: '请选择钻级' }]}
className="worker-consignment-spec-card is-count"
>
<Radio.Group
className="worker-consignment-chips is-count"
optionType="button"
buttonStyle="solid"
options={DIAMOND_OPTIONS.map((o) => ({
label: `${o.value}钻`,
value: o.value,
}))}
/>
</Form.Item>
<Form.Item
name="brillianceCount"
label="璀璨数"
rules={[
{ required: true, message: '请选择璀璨数' },
({ getFieldValue }) => ({
validator(_, value) {
const dia = getFieldValue('diamondLevel')
if (value != null && dia != null && Number(value) > Number(dia)) {
return Promise.reject(
new Error(`璀璨数不能超过钻石等级(当前${dia}钻最多选${dia}璀)`),
)
}
return Promise.resolve()
},
}),
]}
initialValue={0}
className="worker-consignment-spec-card is-count"
>
<Radio.Group
className="worker-consignment-chips is-count"
optionType="button"
buttonStyle="solid"
options={BRILLIANCE_OPTIONS.map((o) => ({
label: o.value === 0 ? '无' : `${o.value}璀`,
value: o.value,
disabled: currentDiamondLevel != null && o.value > Number(currentDiamondLevel),
}))}
/>
</Form.Item>
</div>
<div className="worker-consignment-price-grid">
<Form.Item
label="寄售价格"
tooltip={
manualPriceAllowed
? '当前寄卖配置允许自定义售价,价格不得低于配置的最低价。'
: '由后台「接单模板」按系统/系列/钻级/璀璨自动匹配,卖家无需填写;需要调整请联系客服。'
}
className="worker-consignment-spec-card is-price"
>
{manualPriceAllowed ? (
<Form.Item name="askingPrice" noStyle>
<InputNumber
min={Math.max(1, Number(selectedProfile?.config.pricing.minPriceFen || 0) / 100)}
precision={2}
addonBefore="¥"
style={{ width: '100%' }}
/>
</Form.Item>
) : (
<div className="worker-consignment-price-readonly">
{priceState.status === 'loading' ? (
<span className="is-pending">
<Spin size="small" /> 匹配模板价…
</span>
) : priceState.status === 'matched' ? (
<span className="is-amount">¥{(priceState.fen / 100).toFixed(0)}</span>
) : priceState.status === 'empty' ? (
<span className="is-empty">该规格暂无模板价,请联系客服</span>
) : (
<span className="is-empty">选完规格后自动带出</span>
)}
</div>
)}
</Form.Item>
{selectedProfile?.config.fields.quantity !== false ? (
<Form.Item
label="寄售数量"
name="quantity"
initialValue={1}
className="worker-consignment-spec-card is-price"
>
<InputNumber min={1} precision={0} addonAfter="件" style={{ width: '100%' }} />
</Form.Item>
) : null}
<Form.Item
label="履约押金"
tooltip="平台统一配置。发布成功后暂扣,驳回或履约完成后释放。"
className="worker-consignment-spec-card is-price"
>
<InputNumber
size="large"
disabled
value={publishDepositYuan}
style={{ width: '100%' }}
addonBefore="¥"
/>
</Form.Item>
<Form.Item name="itemName" hidden>
<Input />
</Form.Item>
</div>
</div>
<Divider plain className="worker-consignment-divider">
联系方式与游戏凭据
</Divider>
<Row gutter={[12, 0]}>
<Col xs={24} sm={12}>
<Form.Item
name="contactSupporterId"
label="对接客服"
rules={[{ required: true, message: '请选择对接客服' }]}
extra={
contactSupporters.length === 0
? '暂无可选客服,请联系管理员配置'
: '用于后续提成归属与联系'
}
>
<Select
placeholder="请选择对接客服"
options={contactSupporters.map((item) => ({
value: item.id,
label: item.name,
}))}
notFoundContent="暂无启用中的对接客服"
showSearch
optionFilterProp="label"
/>
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item
name="sellerContact"
label="微信号 / QQ号"
rules={[{ required: true, message: '请填写微信号或QQ' }]}
>
<Input placeholder="方便派单与紧急联系" />
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item name="sellerPhone" label="联系电话" tooltip="默认使用当前接单账号绑定手机号">
<Input placeholder="手机号" />
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item
name="characterName"
label="游戏内角色名"
rules={[{ required: true, message: '请填写角色名' }]}
>
<Input placeholder="局内加好友丢枪用" />
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item
name="characterId"
label="角色数字ID"
rules={[{ required: true, message: '请填写数字ID' }]}
>
<Input placeholder="如 1089238472" />
</Form.Item>
</Col>
<Col span={24}>
<Form.Item
label="游戏主页截图"
required
extra="须包含角色名、ID 及仓库/手枪详情,最多 4 张"
>
<ImageUpload
value={screenshots}
onChange={setScreenshots}
scene="consignment_screenshot"
scope="worker"
maxCount={4}
showPasteHint={!isMobile}
/>
</Form.Item>
</Col>
<Col span={24}>
<Form.Item name="sellerNote" label="其他留言与补充">
<Input.TextArea rows={2} placeholder="配件、子弹、涂装或在线时段等(选填)" />
</Form.Item>
</Col>
</Row>
<div className="worker-consignment-actions">
<Button
type="primary"
htmlType="submit"
size="large"
loading={submitting}
disabled={!canPublish}
icon={<CloudUploadOutlined />}
>
{canPublish
? `提交寄卖(暂扣押金 ¥${publishDepositYuan.toFixed(0)})`
: '余额不足,请先充值押金'}
</Button>
<Typography.Text type="secondary">
提交后进入客服审核;押金在驳回或履约完成后释放
</Typography.Text>
</div>
</Form>
)
const publishTabs =
consignmentProfiles.length > 0
? consignmentProfiles.map((profile) => ({
key: `publish-${profile.profileId}`,
label: (
<span>
<DollarCircleOutlined /> {profile.tabTitle || '发布寄卖'}
</span>
),
children: publishTabContent,
}))
: [
{
key: 'publish',
label: (
<span>
<DollarCircleOutlined /> 发布寄卖
</span>
),
children: publishTabContent,
},
]
return (
<section className="page-stack worker-consignment-page">
<div className="worker-orders-desktop-header">
@@ -635,324 +1018,26 @@ export default function WorkerConsignmentPage() {
<Card bordered={false} className="worker-orders-card">
<Tabs
className="worker-order-tabs"
activeKey={activeTab}
onChange={(key) => setActiveTab(key as 'publish' | 'records')}
activeKey={
activeTab === 'records'
? 'records'
: selectedProfile
? `publish-${selectedProfile.profileId}`
: 'publish'
}
onChange={(key) => {
if (key === 'records') {
setActiveTab('records')
return
}
const profileId = Number(String(key).replace('publish-', ''))
if (Number.isInteger(profileId) && profileId > 0) {
setSelectedProfileId(profileId)
}
setActiveTab('publish')
}}
items={[
{
key: 'publish',
label: (
<span>
<DollarCircleOutlined /> 发布寄卖
</span>
),
children: profileQuery.isLoading ? (
<div style={{ padding: '48px 16px', textAlign: 'center' }}>
<Spin />
</div>
) : !canConsign ? (
<div style={{ padding: '36px 16px', textAlign: 'center' }}>
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<div style={{ maxWidth: 420, margin: '0 auto' }}>
<Typography.Title level={5} style={{ marginBottom: 8 }}>
寄卖功能暂未开通
</Typography.Title>
<Typography.Paragraph
type="secondary"
style={{ fontSize: 13, marginBottom: 20 }}
>
平台当前开启寄卖白名单。请联系客服申请开通,由后台运营开通后即可发布苏尔南寄卖。
</Typography.Paragraph>
<Button
type="primary"
icon={<CustomerServiceOutlined />}
onClick={() => setContactOpen(true)}
>
联系客服开通寄卖
</Button>
</div>
}
/>
</div>
) : (
<Form
form={form}
layout="vertical"
className="worker-consignment-form"
onFinish={handlePublishSubmit}
onValuesChange={handleFormValuesChange}
>
<Alert
type={canPublish ? 'info' : 'warning'}
showIcon
icon={<SafetyCertificateOutlined />}
className="worker-consignment-tip"
message={
canPublish
? `履约押金 ¥${publishDepositYuan.toFixed(0)}:发布成功后暂扣,驳回或履约完成后释放。当前可用余额 ¥${(availableAmount / 100).toFixed(2)}`
: `可用余额不足 ¥${publishDepositYuan.toFixed(0)}。请先联系客服充值履约押金后,才能发布寄售。当前可用 ¥${(availableAmount / 100).toFixed(2)}`
}
/>
{!consignmentContactsQuery.isLoading && contactSupporters.length === 0 ? (
<Alert
type="warning"
showIcon
message="暂无可选的对接客服,请联系管理员在「配置 - 对接客服」中添加后再发布"
/>
) : null}
<Divider plain className="worker-consignment-divider">
规格与价格
</Divider>
<div className="worker-consignment-specs">
<div className="worker-consignment-spec-grid">
<Form.Item
name="system"
label="操作系统"
rules={[{ required: true, message: '请选择系统' }]}
className="worker-consignment-spec-card"
>
<Radio.Group
className="worker-consignment-chips"
optionType="button"
buttonStyle="solid"
options={[...SURNAN_PUBLISH_SYSTEM_OPTIONS]}
/>
</Form.Item>
<Form.Item
name="serverZone"
label="游戏区服"
rules={[{ required: true, message: '请选择区服' }]}
// 苏尔南区服固定「通用」(QQ/微信互通),唯一选项默认选中
initialValue={SURNAN_DEFAULT_ZONE}
className="worker-consignment-spec-card"
>
<Radio.Group
className="worker-consignment-chips"
optionType="button"
buttonStyle="solid"
options={[...SURNAN_PUBLISH_ZONE_OPTIONS]}
/>
</Form.Item>
<Form.Item
name="series"
label="系列 / 枪系"
rules={[{ required: true, message: '请选择枪系' }]}
className="worker-consignment-spec-card"
>
<Radio.Group
className="worker-consignment-chips"
optionType="button"
buttonStyle="solid"
options={SERIES_OPTIONS}
/>
</Form.Item>
<Form.Item
name="diamondLevel"
label="钻石等级"
rules={[{ required: true, message: '请选择钻级' }]}
className="worker-consignment-spec-card is-count"
>
<Radio.Group
className="worker-consignment-chips is-count"
optionType="button"
buttonStyle="solid"
options={DIAMOND_OPTIONS.map((o) => ({
label: `${o.value}钻`,
value: o.value,
}))}
/>
</Form.Item>
<Form.Item
name="brillianceCount"
label="璀璨数"
rules={[
{ required: true, message: '请选择璀璨数' },
({ getFieldValue }) => ({
validator(_, value) {
const dia = getFieldValue('diamondLevel')
if (value != null && dia != null && Number(value) > Number(dia)) {
return Promise.reject(
new Error(`璀璨数不能超过钻石等级(当前${dia}钻最多选${dia}璀)`),
)
}
return Promise.resolve()
},
}),
]}
initialValue={0}
className="worker-consignment-spec-card is-count"
>
<Radio.Group
className="worker-consignment-chips is-count"
optionType="button"
buttonStyle="solid"
options={BRILLIANCE_OPTIONS.map((o) => ({
label: o.value === 0 ? '无' : `${o.value}璀`,
value: o.value,
disabled:
currentDiamondLevel != null && o.value > Number(currentDiamondLevel),
}))}
/>
</Form.Item>
</div>
<div className="worker-consignment-price-grid">
<Form.Item
label="寄售价格"
tooltip="由后台「接单模板」按系统/系列/钻级/璀璨自动匹配,卖家无需填写;需要调整请联系客服。"
className="worker-consignment-spec-card is-price"
>
<div className="worker-consignment-price-readonly">
{priceState.status === 'loading' ? (
<span className="is-pending">
<Spin size="small" /> 匹配模板价…
</span>
) : priceState.status === 'matched' ? (
<span className="is-amount">¥{(priceState.fen / 100).toFixed(0)}</span>
) : priceState.status === 'empty' ? (
<span className="is-empty">该规格暂无模板价,请联系客服</span>
) : (
<span className="is-empty">选完规格后自动带出</span>
)}
</div>
</Form.Item>
{/* 价格只读展示,但仍要随表单提交给后端(后端也会按模板复算) */}
<Form.Item name="askingPrice" hidden>
<InputNumber />
</Form.Item>
<Form.Item
label="履约押金"
tooltip="平台统一配置。发布成功后暂扣,驳回或履约完成后释放。"
className="worker-consignment-spec-card is-price"
>
<InputNumber
size="large"
disabled
value={publishDepositYuan}
style={{ width: '100%' }}
addonBefore="¥"
/>
</Form.Item>
<Form.Item name="itemName" hidden>
<Input />
</Form.Item>
</div>
</div>
<Divider plain className="worker-consignment-divider">
联系方式与游戏凭据
</Divider>
<Row gutter={[12, 0]}>
<Col xs={24} sm={12}>
<Form.Item
name="contactSupporterId"
label="对接客服"
rules={[{ required: true, message: '请选择对接客服' }]}
extra={
contactSupporters.length === 0
? '暂无可选客服,请联系管理员配置'
: '用于后续提成归属与联系'
}
>
<Select
placeholder="请选择对接客服"
options={contactSupporters.map((item) => ({
value: item.id,
label: item.name,
}))}
notFoundContent="暂无启用中的对接客服"
showSearch
optionFilterProp="label"
/>
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item
name="sellerContact"
label="微信号 / QQ号"
rules={[{ required: true, message: '请填写微信号或QQ' }]}
>
<Input placeholder="方便派单与紧急联系" />
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item
name="sellerPhone"
label="联系电话"
tooltip="默认使用当前接单账号绑定手机号"
>
<Input placeholder="手机号" />
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item
name="characterName"
label="游戏内角色名"
rules={[{ required: true, message: '请填写角色名' }]}
>
<Input placeholder="局内加好友丢枪用" />
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item
name="characterId"
label="角色数字ID"
rules={[{ required: true, message: '请填写数字ID' }]}
>
<Input placeholder="如 1089238472" />
</Form.Item>
</Col>
<Col span={24}>
<Form.Item
label="游戏主页截图"
required
extra="须包含角色名、ID 及仓库/手枪详情,最多 4 张"
>
<ImageUpload
value={screenshots}
onChange={setScreenshots}
scene="consignment_screenshot"
scope="worker"
maxCount={4}
showPasteHint={!isMobile}
/>
</Form.Item>
</Col>
<Col span={24}>
<Form.Item name="sellerNote" label="其他留言与补充">
<Input.TextArea
rows={2}
placeholder="配件、子弹、涂装或在线时段等(选填)"
/>
</Form.Item>
</Col>
</Row>
<div className="worker-consignment-actions">
<Button
type="primary"
htmlType="submit"
size="large"
loading={submitting}
disabled={!canPublish}
icon={<CloudUploadOutlined />}
>
{canPublish
? `提交寄卖(暂扣押金 ¥${publishDepositYuan.toFixed(0)})`
: '余额不足,请先充值押金'}
</Button>
<Typography.Text type="secondary">
提交后进入客服审核;押金在驳回或履约完成后释放
</Typography.Text>
</div>
</Form>
),
},
...publishTabs,
{
key: 'records',
label: (
@@ -1361,6 +1446,11 @@ function ConsignmentRecordCard({
onConfirmFriend: (record: WorkerConsignmentRecord) => void
onSubmitEvidence: (record: WorkerConsignmentRecord) => void
}) {
const { message } = App.useApp()
const queryClient = useQueryClient()
const [priceOpen, setPriceOpen] = useState(false)
const [priceYuan, setPriceYuan] = useState(0)
const [savingPrice, setSavingPrice] = useState(false)
const shots = Array.isArray(record.screenshotsJson)
? record.screenshotsJson.filter((s): s is string => typeof s === 'string')
: []
@@ -1393,8 +1483,17 @@ function ConsignmentRecordCard({
/>
<div className="worker-consignment-record-meta">
<span>
寄售价 <strong>¥{(record.askingPriceFen / 100).toFixed(2)}</strong>
寄售价{' '}
<strong>¥{((record.salePriceFen ?? record.askingPriceFen) / 100).toFixed(2)}</strong>
</span>
{Number(record.quantity || 1) > 1 ? (
<span>
数量{' '}
<strong>
{record.remainingQuantity ?? record.quantity} / {record.quantity} 件
</strong>
</span>
) : null}
<span>
押金 <strong>¥{(Number(record.depositAmountFen || 0) / 100).toFixed(2)}</strong>
</span>
@@ -1402,7 +1501,28 @@ function ConsignmentRecordCard({
对接客服 <strong>{formatConsignmentContactSupporter(record).text}</strong>
</span>
{record.workOrderId ? <span>履约单 #{record.workOrderId}</span> : null}
{record.canEditPrice && record.submissionId ? (
<Button
size="small"
onClick={() => {
setPriceYuan(Number(record.salePriceFen ?? record.askingPriceFen) / 100)
setPriceOpen(true)
}}
>
修改售价
</Button>
) : null}
</div>
{record.priceDropSuggestion ? (
<Alert
type="warning"
showIcon
style={{ marginTop: 8 }}
message={`已超过 ${record.priceDropSuggestion.dropHours} 小时未售出,建议降价至 ¥${(
record.priceDropSuggestion.suggestedPriceFen / 100
).toFixed(2)}(仅提示,不会自动改价)`}
/>
) : null}
{record.reviewNote ? (
<Typography.Text type="danger">审核说明:{record.reviewNote}</Typography.Text>
) : null}
@@ -1549,6 +1669,39 @@ function ConsignmentRecordCard({
<Typography.Text type="secondary" className="worker-consignment-record-time">
{formatDateTime(record.createdAt)}
</Typography.Text>
<Modal
title="修改寄卖售价"
open={priceOpen}
onCancel={() => setPriceOpen(false)}
confirmLoading={savingPrice}
okText="保存"
onOk={async () => {
if (!record.submissionId) return
setSavingPrice(true)
try {
await updateWorkerConsignmentPrice(
record.submissionId,
Math.max(100, Math.round(Number(priceYuan || 0) * 100)),
)
message.success('寄卖售价已更新')
setPriceOpen(false)
await queryClient.invalidateQueries({ queryKey: ['worker-my-consignments'] })
} catch (error) {
message.error(error instanceof Error ? error.message : '修改售价失败')
} finally {
setSavingPrice(false)
}
}}
>
<InputNumber
min={1}
precision={2}
addonBefore="¥"
value={priceYuan}
onChange={(value) => setPriceYuan(Number(value || 0))}
style={{ width: '100%' }}
/>
</Modal>
</article>
)
}
@@ -1,7 +1,10 @@
import { apiDelete, apiGet, apiPatch, apiPost, apiPut } from '@/lib/http'
import type {
AdminConsignmentProfilesResponse,
ConsignmentContactSupporter,
ConsignmentInventory,
ConsignmentMatchCandidatesResponse,
ConsignmentProfile,
ConsignmentStatsSummary,
ConsignmentSubmission,
InventoryMatchableOrders,
@@ -10,6 +13,54 @@ import type {
WorkOrderInventoryMatchOptions,
} from '@/types/consignment'
// --- 分类级寄卖配置 ---
export function fetchAdminConsignmentProfiles() {
return apiGet<AdminConsignmentProfilesResponse>(
'/api/v1/admin/worker-platform/consignment/profiles',
)
}
export function saveAdminConsignmentProfile(payload: Record<string, unknown>) {
return apiPost<{ profile: ConsignmentProfile }>(
'/api/v1/admin/worker-platform/consignment/profiles',
payload,
)
}
export function publishAdminConsignmentProfile(profileId: number | string) {
return apiPost<{ profile: ConsignmentProfile }>(
`/api/v1/admin/worker-platform/consignment/profiles/${profileId}/publish`,
{},
)
}
export function archiveAdminConsignmentProfile(profileId: number | string) {
return apiPost<{ profile: ConsignmentProfile }>(
`/api/v1/admin/worker-platform/consignment/profiles/${profileId}/archive`,
{},
)
}
// --- 内部匹配(工单侧手动匹配) ---
export function fetchAdminConsignmentMatchCandidates(workOrderId: number | string) {
return apiGet<ConsignmentMatchCandidatesResponse>(
'/api/v1/admin/worker-platform/consignment/match-candidates',
{ workOrderId },
)
}
export function matchAdminConsignmentWorkOrder(payload: {
workOrderId: number
inventoryIds: number[]
}) {
return apiPost<{ matched: boolean; matchedCount: number }>(
'/api/v1/admin/worker-platform/consignment/match',
payload,
)
}
// --- 对接客服名单 ---
export function fetchAdminConsignmentContacts(params?: { keyword?: string; enabled?: string }) {
+31 -1
View File
@@ -1,4 +1,4 @@
import { apiDelete, apiGet, apiPost, apiPostForm, apiPut } from '@/lib/http'
import { apiDelete, apiGet, apiPatch, apiPost, apiPostForm, apiPut } from '@/lib/http'
import { optimizeImageForUpload } from '@/utils/image-upload'
import type {
UploadedFile,
@@ -31,6 +31,7 @@ import type {
ConsignmentBuyerInfo,
ConsignmentContactSupporter,
ConsignmentSellerInfo,
WorkerConsignmentProfile,
} from '@/types/consignment'
import { getWorkerDeviceId } from '@/utils/worker-auth'
@@ -373,6 +374,9 @@ export function supplementWorkerAcceptedOrderEvidence(
// ---------------- 打手端:我要寄卖与手枪库存 ----------------
export type WorkerConsignmentPublishPayload = {
/** 分类级寄卖配置 id(打手端 tab 对应) */
profileId?: number
quantity?: number
itemName?: string
system: string
serverZone: string
@@ -448,6 +452,17 @@ export type WorkerConsignmentRecord = {
diamondLevel: number
brillianceCount: number
askingPriceFen: number
salePriceFen?: number
quantity?: number
remainingQuantity?: number
listedAt?: string | null
canEditPrice?: boolean
priceDropSuggestion?: {
suggestedPriceFen: number
dropHours: number
dropMode: 'percent' | 'fixed'
dropValue: number
} | null
depositAmountFen: number
contactSupporterId?: number | null
contactSupporterName?: string
@@ -508,6 +523,21 @@ export function publishWorkerConsignment(payload: WorkerConsignmentPublishPayloa
return apiPost<WorkerConsignmentItem>('/api/v1/worker/consignment/publish', payload)
}
/** 打手端寄卖页:已发布配置 → 动态 tab。 */
export function fetchWorkerConsignmentProfiles() {
return apiGet<{ profiles: WorkerConsignmentProfile[] }>('/api/v1/worker/consignment/profiles')
}
/** 打手修改在售寄卖售价(分)。 */
export function updateWorkerConsignmentPrice(submissionId: number | string, salePriceFen: number) {
return apiPatch<{
submissionId: number
inventoryId: number
salePriceFen: number
updatedAt: string
}>(`/api/v1/worker/consignment/items/${submissionId}/price`, { salePriceFen })
}
/** 发布寄卖时的「对接客服」下拉(仅启用中的名单)。 */
export function fetchWorkerConsignmentContacts() {
return apiGet<{ items: ConsignmentContactSupporter[] }>('/api/v1/worker/consignment/contacts')
+128
View File
@@ -88,6 +88,23 @@ export type ConsignmentInventory = ConsignmentEvidenceFields &
sourceSubmissionId: number | null
/** 来源提报单号(提报单号即寄卖单号);客服手工入库的现货为空字符串。 */
sourceSubmissionNo?: string
/** 分类级寄卖配置 */
profileId?: number | null
profileVersionId?: number | null
/** 批次总数量 / 剩余可匹配数量 */
quantity?: number
remainingQuantity?: number
/** 寄卖售价(分) */
salePriceFen?: number
listedAt?: string | null
priceUpdatedAt?: string | null
canEditPrice?: boolean
priceDropSuggestion?: {
suggestedPriceFen: number
dropHours: number
dropMode: 'percent' | 'fixed'
dropValue: number
} | null
workerId?: number | null
workerName?: string
workerPhone?: string
@@ -160,6 +177,11 @@ export type ConsignmentSubmission = ConsignmentEvidenceFields &
contentJson: Record<string, unknown>
screenshotsJson: string[]
askingPriceFen: number
/** 分类级寄卖配置与数量/售价 */
profileId?: number | null
profileVersionId?: number | null
quantity?: number
requestedSalePriceFen?: number
workerId?: number | null
workerName?: string
workerPhone?: string
@@ -269,3 +291,109 @@ export type PendingPistolMatchResult = {
truncated: boolean
pairs: PendingPistolMatchPair[]
}
// ---------------------------------------------------------------------------
// 分类级寄卖配置(管理端「寄卖配置」tab / 打手端动态 tab)
// ---------------------------------------------------------------------------
export type ConsignmentProfileConfig = {
version: 1
tabTitle: string
tabDescription: string
fields: {
system: boolean
serverZone: boolean
series: boolean
diamondLevel: boolean
brillianceCount: boolean
quantity: boolean
}
pricing: { useTemplatePrice: boolean; allowWorkerPrice: boolean; minPriceFen: number }
deposit: {
enabled: boolean
mode: 'fixed' | 'per_quantity'
fixedAmountFen: number
unitAmountFen: number
}
serviceFee: { enabled: boolean; mode: 'percent' | 'fixed'; percent: number; amountFen: 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
}
}
export type ConsignmentProfile = {
profileId: number
categoryId: number
categoryName?: string
categoryParentId?: number | null
name: string
title: string
description: string
tabTitle: string
status: 'draft' | 'published' | 'archived'
currentVersionId: number | null
currentVersionNo: number | null
config: ConsignmentProfileConfig
createdAt?: string
updatedAt?: string
}
export type AdminConsignmentProfilesResponse = {
categories: Array<{
categoryId: number
categoryKey: string
name: string
parentId: number | null
status: string
}>
profiles: ConsignmentProfile[]
}
export type WorkerConsignmentProfile = {
profileId: number
categoryId: number
categoryName: string
name: string
title: string
description: string
tabTitle: string
tabDescription: string
config: ConsignmentProfileConfig
}
export type ConsignmentMatchCandidate = {
inventoryId: number
inventoryNo: string
itemName: string
system: string
serverZone: string
series: string
diamondLevel: number
brillianceCount: number
quantity: number
remainingQuantity: number
salePriceFen: number
listedAt: string | null
workerId: number | null
workerName: string
}
export type ConsignmentMatchCandidatesResponse = {
workOrderId: number
profileId: number | null
sharingEnabled: boolean
totalQuantity: number
activeQuantity: number
options: ConsignmentMatchCandidate[]
}
@@ -23,6 +23,7 @@ export const WORKER_PLATFORM_TAB_KEYS: readonly string[] = [
'announcement',
'rules',
'order-service',
'consignment-config',
'product-match',
'categories',
'workers',
@@ -55,6 +56,7 @@ export const WORKER_PLATFORM_TAB_QUERY_KEYS: Record<string, string[][]> = {
['admin-order-service-auto-publish-jobs'],
['admin-categories'],
],
'consignment-config': [['admin-consignment-profiles'], ['admin-categories']],
'product-match': [['admin-product-match']],
categories: [['admin-categories']],
workers: [['admin-workers'], ['admin-worker-users']],