feat(consignment): 寄卖配置、内部匹配与打手端升级
This commit is contained in:
@@ -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 }) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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']],
|
||||
|
||||
Reference in New Issue
Block a user