feat(consignment): 寄卖配置价格/匹配/超时/收货字段全面改造
- 价格:固定价格改为模板价固定下调,移除绝对固定价;没有模板价时统一阻止发布 - 配置面板:按下拉选项动态显隐,超时与降价提示拆成两张卡片,超时单位改为小时(默认 0.5) - 匹配:新增关闭匹配并设为默认,移除自动匹配开关;关闭后新单直接进大厅 - 超时:新增重新匹配退/扣押金,回内部匹配池并排除原卖家与原库存(迁移 147) - 日志:新增 consignment_match_logs,自动/手动匹配成功、失败、跳过统一留痕,后台新增匹配日志抽屉 - 打手端:顶部分类下拉替换多 tab,默认苏尔南手枪;联系方式与游戏凭据改为动态字段(默认 7 项,手机号必填) - 降价提示默认开启、默认 24 小时
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
import { ReloadOutlined } from '@ant-design/icons'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Input, Modal, Select, Space, Tag, Typography } from 'antd'
|
||||
import { useState } from 'react'
|
||||
|
||||
import PercentTable from '@/components/common/PercentTable'
|
||||
import { fetchAdminConsignmentMatchLogs } from '@/services/admin/consignment'
|
||||
import type { ConsignmentMatchLogItem } from '@/types/consignment-match-log'
|
||||
import { formatDateTime } from '@/utils/date-time'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
const TRIGGER_LABELS: Record<string, string> = {
|
||||
auto: '自动匹配',
|
||||
manual: '手动匹配',
|
||||
rematch: '重新匹配',
|
||||
}
|
||||
|
||||
const RESULT_LABELS: Record<string, { label: string; color: string }> = {
|
||||
matched: { label: '已匹配', color: 'green' },
|
||||
failed: { label: '失败', color: 'red' },
|
||||
skipped: { label: '跳过', color: 'default' },
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export default function ConsignmentMatchLogsModal({
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [page, setPage] = useState(1)
|
||||
const [triggerType, setTriggerType] = useState<string | undefined>()
|
||||
const [result, setResult] = useState<string | undefined>()
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [keywordInput, setKeywordInput] = useState('')
|
||||
|
||||
const logsQuery = useQuery({
|
||||
queryKey: ['admin-consignment-match-logs', page, triggerType, result, keyword],
|
||||
queryFn: () =>
|
||||
fetchAdminConsignmentMatchLogs({
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
triggerType,
|
||||
result,
|
||||
keyword: keyword || undefined,
|
||||
}),
|
||||
enabled: open,
|
||||
})
|
||||
|
||||
const data = logsQuery.data?.data
|
||||
const items = data?.items || []
|
||||
const total = Number(data?.total || 0)
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 160,
|
||||
render: (value: string) => (value ? formatDateTime(value) : '-'),
|
||||
},
|
||||
{
|
||||
title: '工单',
|
||||
dataIndex: 'workOrderNo',
|
||||
width: 150,
|
||||
render: (_: string, record: ConsignmentMatchLogItem) =>
|
||||
record.workOrderNo || (record.workOrderId ? `#${record.workOrderId}` : '-'),
|
||||
},
|
||||
{
|
||||
title: '触发',
|
||||
dataIndex: 'triggerType',
|
||||
width: 100,
|
||||
render: (value: string) => (
|
||||
<Tag color={value === 'manual' ? 'blue' : value === 'rematch' ? 'orange' : 'purple'}>
|
||||
{TRIGGER_LABELS[value] || value}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '结果',
|
||||
dataIndex: 'result',
|
||||
width: 90,
|
||||
render: (value: string) => {
|
||||
const config = RESULT_LABELS[value] || { label: value, color: 'default' }
|
||||
return <Tag color={config.color}>{config.label}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '候选 / 成功',
|
||||
width: 100,
|
||||
render: (_: unknown, record: ConsignmentMatchLogItem) =>
|
||||
`${record.candidateCount} / ${record.matchedCount}`,
|
||||
},
|
||||
{
|
||||
title: '原因',
|
||||
dataIndex: 'reasonText',
|
||||
render: (value: string, record: ConsignmentMatchLogItem) => (
|
||||
<div style={{ display: 'grid', gap: 2 }}>
|
||||
<Text>{value || '-'}</Text>
|
||||
{record.reasonCode ? (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{record.reasonCode}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作人',
|
||||
dataIndex: 'actorId',
|
||||
width: 120,
|
||||
render: (value: string, record: ConsignmentMatchLogItem) =>
|
||||
record.actorType === 'system' ? '系统' : value || '-',
|
||||
},
|
||||
{
|
||||
title: '耗时',
|
||||
dataIndex: 'durationMs',
|
||||
width: 80,
|
||||
render: (value: number) => `${Number(value || 0)} ms`,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="寄卖匹配日志"
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={1080}
|
||||
destroyOnClose
|
||||
>
|
||||
<Space wrap style={{ marginBottom: 12 }}>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="触发方式"
|
||||
style={{ width: 130 }}
|
||||
value={triggerType}
|
||||
onChange={(value) => {
|
||||
setTriggerType(value)
|
||||
setPage(1)
|
||||
}}
|
||||
options={[
|
||||
{ value: 'auto', label: '自动匹配' },
|
||||
{ value: 'manual', label: '手动匹配' },
|
||||
{ value: 'rematch', label: '重新匹配' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="结果"
|
||||
style={{ width: 120 }}
|
||||
value={result}
|
||||
onChange={(value) => {
|
||||
setResult(value)
|
||||
setPage(1)
|
||||
}}
|
||||
options={[
|
||||
{ value: 'matched', label: '已匹配' },
|
||||
{ value: 'failed', label: '失败' },
|
||||
{ value: 'skipped', label: '跳过' },
|
||||
]}
|
||||
/>
|
||||
<Input.Search
|
||||
allowClear
|
||||
placeholder="搜索工单号"
|
||||
style={{ width: 220 }}
|
||||
value={keywordInput}
|
||||
onChange={(event) => setKeywordInput(event.target.value)}
|
||||
onSearch={(value) => {
|
||||
setKeyword(value.trim())
|
||||
setPage(1)
|
||||
}}
|
||||
/>
|
||||
<Text type="secondary">共 {total} 条</Text>
|
||||
<ReloadOutlined style={{ cursor: 'pointer' }} onClick={() => void logsQuery.refetch()} />
|
||||
</Space>
|
||||
<PercentTable<ConsignmentMatchLogItem>
|
||||
size="small"
|
||||
rowKey="logId"
|
||||
loading={logsQuery.isFetching}
|
||||
dataSource={items}
|
||||
columns={columns}
|
||||
scroll={{ x: 820 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize: PAGE_SIZE,
|
||||
total,
|
||||
showSizeChanger: false,
|
||||
onChange: (nextPage) => setPage(nextPage),
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -39,12 +39,18 @@ import {
|
||||
} from '@/services/admin/consignment'
|
||||
import type {
|
||||
AdminConsignmentProfilesResponse,
|
||||
ConsignmentIntakeField,
|
||||
ConsignmentProfile,
|
||||
ConsignmentSpecField,
|
||||
ConsignmentSpecFieldType,
|
||||
ConsignmentSpecOption,
|
||||
} from '@/types/consignment'
|
||||
import { SPEC_FIELD_TYPE_OPTIONS, isOptionSpecField } from '@/utils/consignment-spec'
|
||||
import {
|
||||
DEFAULT_CONSIGNMENT_INTAKE_FIELDS,
|
||||
INTAKE_FIELD_TYPE_OPTIONS,
|
||||
SPEC_FIELD_TYPE_OPTIONS,
|
||||
isOptionSpecField,
|
||||
} from '@/utils/consignment-spec'
|
||||
|
||||
const { Text, Title } = Typography
|
||||
|
||||
@@ -58,9 +64,9 @@ type ConfigFormValues = {
|
||||
tabTitle: string
|
||||
fields: { quantity: boolean }
|
||||
pricing: {
|
||||
priceType: 'template_discount' | 'fixed'
|
||||
priceType: 'template_discount' | 'template_minus'
|
||||
discount: number
|
||||
fixedPriceYuan: number
|
||||
minusYuan: number
|
||||
allowWorkerPrice: boolean
|
||||
minPriceYuan: number
|
||||
missingPricePolicy: 'block' | 'manual'
|
||||
@@ -75,13 +81,15 @@ type ConfigFormValues = {
|
||||
serviceFee: { enabled: boolean; mode: 'percent' | 'fixed'; percent: number; amountYuan: number }
|
||||
inventoryLimit: { enabled: boolean; maxActiveQuantity: number }
|
||||
matching: {
|
||||
mode: 'manual' | 'auto'
|
||||
autoMatchEnabled: boolean
|
||||
mode: 'off' | 'manual' | 'auto'
|
||||
exactOnly: boolean
|
||||
preferLowReward: boolean
|
||||
}
|
||||
internalMatch: { delayMinutes: number; fallbackToHall: boolean }
|
||||
timeout: { minutes: number; policy: 'reopen' | 'cancel_release' | 'cancel_deduct' }
|
||||
timeout: {
|
||||
hours: number
|
||||
policy: 'reopen' | 'cancel_release' | 'cancel_deduct' | 'rematch_release' | 'rematch_deduct'
|
||||
}
|
||||
priceDrop: { enabled: boolean; hours: number; mode: 'percent' | 'fixed'; value: number }
|
||||
}
|
||||
|
||||
@@ -106,6 +114,29 @@ function cloneSpecs(specs: ConsignmentSpecField[]): ConsignmentSpecField[] {
|
||||
}))
|
||||
}
|
||||
|
||||
function cloneIntakeFields(fields: ConsignmentIntakeField[]): ConsignmentIntakeField[] {
|
||||
return fields.map((field) => ({ ...field }))
|
||||
}
|
||||
|
||||
function nextIntakeKey(fields: ConsignmentIntakeField[]): string {
|
||||
let index = 1
|
||||
while (fields.some((field) => field.key === `custom_${index}`)) index += 1
|
||||
return `custom_${index}`
|
||||
}
|
||||
|
||||
function newIntakeField(fields: ConsignmentIntakeField[]): ConsignmentIntakeField {
|
||||
return {
|
||||
key: nextIntakeKey(fields),
|
||||
label: '新字段',
|
||||
type: 'text',
|
||||
required: false,
|
||||
placeholder: '',
|
||||
helpText: '',
|
||||
maxCount: 0,
|
||||
sortOrder: (fields.length + 1) * 10,
|
||||
}
|
||||
}
|
||||
|
||||
function nextCustomSpecKey(specs: ConsignmentSpecField[]): string {
|
||||
let index = 1
|
||||
while (specs.some((spec) => spec.key === `custom_${index}`)) index += 1
|
||||
@@ -137,12 +168,13 @@ function toFormValues(profile: ConsignmentProfile): ConfigFormValues {
|
||||
tabTitle: profile.tabTitle,
|
||||
fields: { quantity: config.fields.quantity },
|
||||
pricing: {
|
||||
priceType: config.pricing.priceType || 'template_discount',
|
||||
priceType:
|
||||
config.pricing.priceType === 'template_minus' ? 'template_minus' : 'template_discount',
|
||||
discount: Number(config.pricing.discount ?? 10),
|
||||
fixedPriceYuan: yuan(config.pricing.fixedPriceFen),
|
||||
minusYuan: yuan(config.pricing.minusFen),
|
||||
allowWorkerPrice: config.pricing.allowWorkerPrice !== false,
|
||||
minPriceYuan: yuan(config.pricing.minPriceFen),
|
||||
missingPricePolicy: config.pricing.missingPricePolicy || 'manual',
|
||||
missingPricePolicy: 'block',
|
||||
},
|
||||
nameTemplate: config.nameTemplate || '',
|
||||
deposit: {
|
||||
@@ -158,9 +190,16 @@ function toFormValues(profile: ConsignmentProfile): ConfigFormValues {
|
||||
amountYuan: yuan(config.serviceFee.amountFen),
|
||||
},
|
||||
inventoryLimit: { ...config.inventoryLimit },
|
||||
matching: { ...config.matching },
|
||||
matching: {
|
||||
mode: config.matching.mode || 'off',
|
||||
exactOnly: config.matching.exactOnly,
|
||||
preferLowReward: config.matching.preferLowReward,
|
||||
},
|
||||
internalMatch: { ...config.internalMatch },
|
||||
timeout: { ...config.timeout },
|
||||
timeout: {
|
||||
hours: Math.max(0, Number(config.timeout.minutes ?? 30) / 60),
|
||||
policy: config.timeout.policy,
|
||||
},
|
||||
priceDrop: { ...config.priceDrop },
|
||||
}
|
||||
}
|
||||
@@ -175,28 +214,32 @@ function emptyFormValues(): ConfigFormValues {
|
||||
pricing: {
|
||||
priceType: 'template_discount',
|
||||
discount: 10,
|
||||
fixedPriceYuan: 0,
|
||||
minusYuan: 0,
|
||||
allowWorkerPrice: true,
|
||||
minPriceYuan: 0,
|
||||
missingPricePolicy: 'manual',
|
||||
missingPricePolicy: 'block',
|
||||
},
|
||||
nameTemplate: '',
|
||||
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,
|
||||
mode: 'off',
|
||||
exactOnly: true,
|
||||
preferLowReward: true,
|
||||
},
|
||||
internalMatch: { delayMinutes: 30, fallbackToHall: true },
|
||||
timeout: { minutes: 30, policy: 'reopen' },
|
||||
priceDrop: { enabled: false, hours: 72, mode: 'percent', value: 10 },
|
||||
timeout: { hours: 0.5, policy: 'reopen' },
|
||||
priceDrop: { enabled: true, hours: 24, mode: 'percent', value: 10 },
|
||||
}
|
||||
}
|
||||
|
||||
function buildPayload(categoryId: number, values: ConfigFormValues, specs: ConsignmentSpecField[]) {
|
||||
function buildPayload(
|
||||
categoryId: number,
|
||||
values: ConfigFormValues,
|
||||
specs: ConsignmentSpecField[],
|
||||
intakeFields: ConsignmentIntakeField[],
|
||||
) {
|
||||
const hasSpec = (key: string) => specs.some((spec) => spec.key === key)
|
||||
return {
|
||||
categoryId,
|
||||
@@ -217,32 +260,57 @@ function buildPayload(categoryId: number, values: ConfigFormValues, specs: Consi
|
||||
quantity: values.fields.quantity,
|
||||
},
|
||||
specs,
|
||||
intake: { fields: intakeFields },
|
||||
nameTemplate: values.nameTemplate,
|
||||
pricing: {
|
||||
priceType: values.pricing.priceType,
|
||||
discount: Number(values.pricing.discount || 0),
|
||||
fixedPriceFen: fen(values.pricing.fixedPriceYuan),
|
||||
minusFen: fen(values.pricing.minusYuan),
|
||||
allowWorkerPrice: values.pricing.allowWorkerPrice,
|
||||
minPriceFen: fen(values.pricing.minPriceYuan),
|
||||
missingPricePolicy: values.pricing.missingPricePolicy,
|
||||
// 没有模板价一律阻止发布,不再提供手填兜底。
|
||||
missingPricePolicy: 'block',
|
||||
},
|
||||
deposit: {
|
||||
enabled: values.deposit.enabled,
|
||||
mode: values.deposit.mode,
|
||||
fixedAmountFen: fen(values.deposit.fixedAmountYuan),
|
||||
unitAmountFen: fen(values.deposit.unitAmountYuan),
|
||||
mode: values.deposit.enabled ? values.deposit.mode : 'fixed',
|
||||
fixedAmountFen: values.deposit.enabled ? fen(values.deposit.fixedAmountYuan) : 0,
|
||||
unitAmountFen: values.deposit.enabled ? fen(values.deposit.unitAmountYuan) : 0,
|
||||
},
|
||||
serviceFee: {
|
||||
enabled: values.serviceFee.enabled,
|
||||
mode: values.serviceFee.mode,
|
||||
percent: values.serviceFee.percent,
|
||||
amountFen: fen(values.serviceFee.amountYuan),
|
||||
mode: values.serviceFee.enabled ? values.serviceFee.mode : 'percent',
|
||||
percent: values.serviceFee.enabled ? values.serviceFee.percent : 0,
|
||||
amountFen: values.serviceFee.enabled ? fen(values.serviceFee.amountYuan) : 0,
|
||||
},
|
||||
inventoryLimit: {
|
||||
enabled: values.inventoryLimit.enabled,
|
||||
maxActiveQuantity: values.inventoryLimit.enabled
|
||||
? Number(values.inventoryLimit.maxActiveQuantity || 0)
|
||||
: 0,
|
||||
},
|
||||
matching: {
|
||||
mode: values.matching.mode,
|
||||
exactOnly: values.matching.exactOnly,
|
||||
preferLowReward: values.matching.preferLowReward,
|
||||
},
|
||||
internalMatch: {
|
||||
// 关闭匹配时不再等待内部库存,直接进大厅。
|
||||
delayMinutes:
|
||||
values.matching.mode === 'off' ? 0 : Number(values.internalMatch.delayMinutes || 0),
|
||||
fallbackToHall:
|
||||
values.matching.mode === 'off' ? false : values.internalMatch.fallbackToHall,
|
||||
},
|
||||
timeout: {
|
||||
minutes: Math.max(0, Math.round(Number(values.timeout.hours || 0) * 60)),
|
||||
policy: values.timeout.policy,
|
||||
},
|
||||
priceDrop: {
|
||||
enabled: values.priceDrop.enabled,
|
||||
hours: Number(values.priceDrop.hours || 24),
|
||||
mode: values.priceDrop.enabled ? values.priceDrop.mode : 'percent',
|
||||
value: values.priceDrop.enabled ? Number(values.priceDrop.value || 0) : 0,
|
||||
},
|
||||
inventoryLimit: values.inventoryLimit,
|
||||
matching: values.matching,
|
||||
internalMatch: values.internalMatch,
|
||||
timeout: values.timeout,
|
||||
priceDrop: values.priceDrop,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -329,6 +397,7 @@ export default function ConsignmentConfigPanel() {
|
||||
const [expandedCategoryIds, setExpandedCategoryIds] = useState<Set<number>>(() => new Set())
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [specDrafts, setSpecDrafts] = useState<ConsignmentSpecField[]>([])
|
||||
const [intakeDrafts, setIntakeDrafts] = useState<ConsignmentIntakeField[]>([])
|
||||
const [form] = Form.useForm<ConfigFormValues>()
|
||||
|
||||
const profilesQuery = useQuery({
|
||||
@@ -406,6 +475,11 @@ export default function ConsignmentConfigPanel() {
|
||||
if (selectedProfile) {
|
||||
form.setFieldsValue(toFormValues(selectedProfile))
|
||||
setSpecDrafts(cloneSpecs(selectedProfile.config.specs))
|
||||
setIntakeDrafts(
|
||||
cloneIntakeFields(
|
||||
selectedProfile.config.intake?.fields ?? DEFAULT_CONSIGNMENT_INTAKE_FIELDS,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
form.setFieldsValue({
|
||||
...emptyFormValues(),
|
||||
@@ -413,6 +487,7 @@ export default function ConsignmentConfigPanel() {
|
||||
tabTitle: selectedCategory?.name || '',
|
||||
})
|
||||
setSpecDrafts([])
|
||||
setIntakeDrafts(cloneIntakeFields(DEFAULT_CONSIGNMENT_INTAKE_FIELDS))
|
||||
}
|
||||
}, [selectedCategoryId, selectedProfile, selectedCategory?.name, form])
|
||||
|
||||
@@ -424,6 +499,11 @@ export default function ConsignmentConfigPanel() {
|
||||
if (selectedProfile) {
|
||||
form.setFieldsValue(toFormValues(selectedProfile))
|
||||
setSpecDrafts(cloneSpecs(selectedProfile.config.specs))
|
||||
setIntakeDrafts(
|
||||
cloneIntakeFields(
|
||||
selectedProfile.config.intake?.fields ?? DEFAULT_CONSIGNMENT_INTAKE_FIELDS,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
form.setFieldsValue({
|
||||
@@ -432,6 +512,7 @@ export default function ConsignmentConfigPanel() {
|
||||
tabTitle: selectedCategory?.name || '',
|
||||
})
|
||||
setSpecDrafts([])
|
||||
setIntakeDrafts(cloneIntakeFields(DEFAULT_CONSIGNMENT_INTAKE_FIELDS))
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
@@ -463,10 +544,24 @@ export default function ConsignmentConfigPanel() {
|
||||
return
|
||||
}
|
||||
}
|
||||
const intakeKeys = new Set<string>()
|
||||
for (const field of intakeDrafts) {
|
||||
if (!/^[a-zA-Z][a-zA-Z0-9_]{0,39}$/.test(field.key)) {
|
||||
message.error(`收货字段 Key 不合法:${field.key || '(空)'}`)
|
||||
return
|
||||
}
|
||||
if (intakeKeys.has(field.key)) {
|
||||
message.error(`收货字段 Key 重复:${field.key}`)
|
||||
return
|
||||
}
|
||||
intakeKeys.add(field.key)
|
||||
}
|
||||
const values = await form.validateFields()
|
||||
setSaving(true)
|
||||
try {
|
||||
await saveAdminConsignmentProfile(buildPayload(selectedCategoryId, values, specs))
|
||||
await saveAdminConsignmentProfile(
|
||||
buildPayload(selectedCategoryId, values, specs, intakeDrafts),
|
||||
)
|
||||
message.success('寄卖配置已保存')
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
@@ -560,7 +655,45 @@ export default function ConsignmentConfigPanel() {
|
||||
setSpecDrafts((current) => [...current, newCustomSpec(current)])
|
||||
}
|
||||
|
||||
function updateIntakeField(index: number, patch: Partial<ConsignmentIntakeField>) {
|
||||
setIntakeDrafts((current) =>
|
||||
current.map((field, fieldIndex) => (fieldIndex === index ? { ...field, ...patch } : field)),
|
||||
)
|
||||
}
|
||||
|
||||
function removeIntakeField(index: number) {
|
||||
setIntakeDrafts((current) =>
|
||||
current
|
||||
.filter((_, fieldIndex) => fieldIndex !== index)
|
||||
.map((field, fieldIndex) => ({ ...field, sortOrder: (fieldIndex + 1) * 10 })),
|
||||
)
|
||||
}
|
||||
|
||||
function moveIntakeField(index: number, direction: -1 | 1) {
|
||||
setIntakeDrafts((current) => {
|
||||
const next = [...current]
|
||||
const target = index + direction
|
||||
if (target < 0 || target >= next.length) return current
|
||||
const [item] = next.splice(index, 1)
|
||||
if (!item) return current
|
||||
next.splice(target, 0, item)
|
||||
return next.map((field, fieldIndex) => ({ ...field, sortOrder: (fieldIndex + 1) * 10 }))
|
||||
})
|
||||
}
|
||||
|
||||
function addIntakeField() {
|
||||
setIntakeDrafts((current) => [...current, newIntakeField(current)])
|
||||
}
|
||||
|
||||
const priceType = Form.useWatch(['pricing', 'priceType'], form)
|
||||
const depositEnabled = Form.useWatch(['deposit', 'enabled'], form)
|
||||
const depositMode = Form.useWatch(['deposit', 'mode'], form)
|
||||
const serviceFeeEnabled = Form.useWatch(['serviceFee', 'enabled'], form)
|
||||
const serviceFeeMode = Form.useWatch(['serviceFee', 'mode'], form)
|
||||
const inventoryLimitEnabled = Form.useWatch(['inventoryLimit', 'enabled'], form)
|
||||
const matchingMode = Form.useWatch(['matching', 'mode'], form)
|
||||
const priceDropEnabled = Form.useWatch(['priceDrop', 'enabled'], form)
|
||||
const priceDropMode = Form.useWatch(['priceDrop', 'mode'], form)
|
||||
const headerStatus: ProfileStatusInfo = selectedProfile
|
||||
? resolveProfileStatus(selectedProfile)
|
||||
: runtimeInheritsParent
|
||||
@@ -984,9 +1117,15 @@ export default function ConsignmentConfigPanel() {
|
||||
<ConsignmentSectionTitle
|
||||
index="03"
|
||||
title="价格配置"
|
||||
description="模板折扣价或固定价格;固定价不得高于接单模板价"
|
||||
description="按接单模板价打折,或按模板价固定下调;没有模板价一律阻止发布"
|
||||
/>
|
||||
</div>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="寄售价始终基于接单模板价计算;该规格没有模板价时,打手端会直接阻止发布。"
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
<div style={gridStyle}>
|
||||
<Form.Item
|
||||
label="价格类型"
|
||||
@@ -998,15 +1137,16 @@ export default function ConsignmentConfigPanel() {
|
||||
buttonStyle="solid"
|
||||
options={[
|
||||
{ value: 'template_discount', label: '接单模板价打折' },
|
||||
{ value: 'fixed', label: '固定价格' },
|
||||
{ value: 'template_minus', label: '模板价固定下调' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
{priceType === 'fixed' ? (
|
||||
{priceType === 'template_minus' ? (
|
||||
<Form.Item
|
||||
label="固定价格(元)"
|
||||
name={['pricing', 'fixedPriceYuan']}
|
||||
extra="发布时会按接单模板价校验,超过模板价会被后端拒绝"
|
||||
label="固定下调(元)"
|
||||
name={['pricing', 'minusYuan']}
|
||||
rules={[{ type: 'number', min: 0 }]}
|
||||
extra="售价 = 接单模板价 − 固定下调金额;下调后不足 1 元会被拒绝"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
@@ -1055,19 +1195,6 @@ export default function ConsignmentConfigPanel() {
|
||||
addonBefore="¥"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="没有模板价时"
|
||||
name={['pricing', 'missingPricePolicy']}
|
||||
extra="固定价格不受此项影响"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'manual', label: '允许打手手填' },
|
||||
{ value: 'block', label: '阻止发布' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1088,42 +1215,48 @@ export default function ConsignmentConfigPanel() {
|
||||
>
|
||||
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="保证金模式"
|
||||
name={['deposit', 'mode']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'fixed', label: '固定金额' },
|
||||
{ value: 'per_quantity', label: '按数量' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="固定金额(元)"
|
||||
name={['deposit', 'fixedAmountYuan']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
addonBefore="¥"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="每件金额(元)"
|
||||
name={['deposit', 'unitAmountYuan']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
addonBefore="¥"
|
||||
/>
|
||||
</Form.Item>
|
||||
{depositEnabled ? (
|
||||
<Form.Item
|
||||
label="保证金模式"
|
||||
name={['deposit', 'mode']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'fixed', label: '固定金额' },
|
||||
{ value: 'per_quantity', label: '按数量' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{depositEnabled && depositMode === 'fixed' ? (
|
||||
<Form.Item
|
||||
label="固定金额(元)"
|
||||
name={['deposit', 'fixedAmountYuan']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
addonBefore="¥"
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{depositEnabled && depositMode === 'per_quantity' ? (
|
||||
<Form.Item
|
||||
label="每件金额(元)"
|
||||
name={['deposit', 'unitAmountYuan']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
addonBefore="¥"
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1144,37 +1277,43 @@ export default function ConsignmentConfigPanel() {
|
||||
>
|
||||
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="服务费模式"
|
||||
name={['serviceFee', 'mode']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'percent', label: '按比例 %' },
|
||||
{ value: 'fixed', label: '固定金额' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="比例(%)"
|
||||
name={['serviceFee', 'percent']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="固定金额(元)"
|
||||
name={['serviceFee', 'amountYuan']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
addonBefore="¥"
|
||||
/>
|
||||
</Form.Item>
|
||||
{serviceFeeEnabled ? (
|
||||
<Form.Item
|
||||
label="服务费模式"
|
||||
name={['serviceFee', 'mode']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'percent', label: '按比例 %' },
|
||||
{ value: 'fixed', label: '固定金额' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{serviceFeeEnabled && serviceFeeMode === 'percent' ? (
|
||||
<Form.Item
|
||||
label="比例(%)"
|
||||
name={['serviceFee', 'percent']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{serviceFeeEnabled && serviceFeeMode === 'fixed' ? (
|
||||
<Form.Item
|
||||
label="固定金额(元)"
|
||||
name={['serviceFee', 'amountYuan']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
addonBefore="¥"
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1195,18 +1334,20 @@ export default function ConsignmentConfigPanel() {
|
||||
>
|
||||
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="最多在售数量"
|
||||
name={['inventoryLimit', 'maxActiveQuantity']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={0}
|
||||
style={{ width: '100%' }}
|
||||
addonAfter="件"
|
||||
/>
|
||||
</Form.Item>
|
||||
{inventoryLimitEnabled ? (
|
||||
<Form.Item
|
||||
label="最多在售数量"
|
||||
name={['inventoryLimit', 'maxActiveQuantity']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={0}
|
||||
style={{ width: '100%' }}
|
||||
addonAfter="件"
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1215,7 +1356,7 @@ export default function ConsignmentConfigPanel() {
|
||||
<ConsignmentSectionTitle
|
||||
index="07"
|
||||
title="匹配设置"
|
||||
description="自动/手动匹配、精确匹配与内部匹配等待策略"
|
||||
description="匹配模式决定新单是否进入内部库存匹配队列"
|
||||
/>
|
||||
</div>
|
||||
<div style={gridStyle}>
|
||||
@@ -1226,77 +1367,85 @@ export default function ConsignmentConfigPanel() {
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'off', label: '关闭匹配' },
|
||||
{ value: 'manual', label: '手动匹配' },
|
||||
{ value: 'auto', label: '自动匹配' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="自动匹配开关"
|
||||
name={['matching', 'autoMatchEnabled']}
|
||||
valuePropName="checked"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="仅精确匹配"
|
||||
name={['matching', 'exactOnly']}
|
||||
valuePropName="checked"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Switch checkedChildren="仅精确" unCheckedChildren="允许高配" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="低价订单优先"
|
||||
name={['matching', 'preferLowReward']}
|
||||
valuePropName="checked"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Switch checkedChildren="优先" unCheckedChildren="不优先" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="内部匹配等待(分钟)"
|
||||
name={['internalMatch', 'delayMinutes']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={0}
|
||||
style={{ width: '100%' }}
|
||||
addonAfter="分钟"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="超时发回大厅"
|
||||
name={['internalMatch', 'fallbackToHall']}
|
||||
valuePropName="checked"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Switch checkedChildren="是" unCheckedChildren="否" />
|
||||
</Form.Item>
|
||||
{matchingMode !== 'off' ? (
|
||||
<>
|
||||
<Form.Item
|
||||
label="仅精确匹配"
|
||||
name={['matching', 'exactOnly']}
|
||||
valuePropName="checked"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Switch checkedChildren="仅精确" unCheckedChildren="允许高配" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="低价订单优先"
|
||||
name={['matching', 'preferLowReward']}
|
||||
valuePropName="checked"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Switch checkedChildren="优先" unCheckedChildren="不优先" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="内部匹配等待(分钟)"
|
||||
name={['internalMatch', 'delayMinutes']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={0}
|
||||
style={{ width: '100%' }}
|
||||
addonAfter="分钟"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="超时发回大厅"
|
||||
name={['internalMatch', 'fallbackToHall']}
|
||||
valuePropName="checked"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Switch checkedChildren="是" unCheckedChildren="否" />
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{matchingMode === 'off' ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginTop: 12 }}
|
||||
message="关闭匹配后:新单直接进入接单大厅,不等待内部库存,也不会自动匹配。"
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="order-service-section">
|
||||
<div className="order-service-section-head">
|
||||
<ConsignmentSectionTitle
|
||||
index="08"
|
||||
title="超时与降价提示"
|
||||
description="代练超时动作与寄卖长时间未售出的降价提醒"
|
||||
title="超时设置"
|
||||
description="匹配成功进入代练中后的时限与超时处置动作"
|
||||
/>
|
||||
</div>
|
||||
<div style={gridStyle}>
|
||||
<Form.Item
|
||||
label="代练超时(分钟)"
|
||||
name={['timeout', 'minutes']}
|
||||
label="代练超时(小时)"
|
||||
name={['timeout', 'hours']}
|
||||
rules={[{ type: 'number', min: 0 }]}
|
||||
extra="默认 0.5 小时;到点后按下方超时动作处置"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={0}
|
||||
step={0.5}
|
||||
precision={1}
|
||||
style={{ width: '100%' }}
|
||||
addonAfter="分钟"
|
||||
addonAfter="小时"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
@@ -1306,12 +1455,32 @@ export default function ConsignmentConfigPanel() {
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'reopen', label: '退回大厅' },
|
||||
{ value: 'cancel_release', label: '取消并释放' },
|
||||
{ value: 'cancel_deduct', label: '取消并扣押金' },
|
||||
{ value: 'reopen', label: '退回大厅(退还押金)' },
|
||||
{ value: 'cancel_release', label: '取消并释放押金' },
|
||||
{ value: 'cancel_deduct', label: '取消并扣除押金' },
|
||||
{ value: 'rematch_release', label: '重新匹配并退还押金' },
|
||||
{ value: 'rematch_deduct', label: '重新匹配并扣除押金' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginTop: 12 }}
|
||||
message="重新匹配:订单回到内部匹配池,并排除原卖家与原库存,避免同一卖家立刻接回同一单。"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="order-service-section">
|
||||
<div className="order-service-section-head">
|
||||
<ConsignmentSectionTitle
|
||||
index="09"
|
||||
title="降价提示"
|
||||
description="寄卖长时间未售出时,在后台给出建议售价(仅提示,不自动改价)"
|
||||
/>
|
||||
</div>
|
||||
<div style={gridStyle}>
|
||||
<Form.Item
|
||||
label="开启降价提示"
|
||||
name={['priceDrop', 'enabled']}
|
||||
@@ -1320,40 +1489,186 @@ export default function ConsignmentConfigPanel() {
|
||||
>
|
||||
<Switch checkedChildren="开启" unCheckedChildren="关闭" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="未售出时长(小时)"
|
||||
name={['priceDrop', 'hours']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
precision={0}
|
||||
style={{ width: '100%' }}
|
||||
addonAfter="小时"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="降幅模式"
|
||||
name={['priceDrop', 'mode']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'percent', label: '按比例 %' },
|
||||
{ value: 'fixed', label: '固定金额' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="降幅数值"
|
||||
name={['priceDrop', 'value']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber min={0} precision={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
{priceDropEnabled ? (
|
||||
<>
|
||||
<Form.Item
|
||||
label="未售出时长(小时)"
|
||||
name={['priceDrop', 'hours']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
precision={0}
|
||||
style={{ width: '100%' }}
|
||||
addonAfter="小时"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="降幅模式"
|
||||
name={['priceDrop', 'mode']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'percent', label: '按比例 %' },
|
||||
{ value: 'fixed', label: '固定金额' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={priceDropMode === 'fixed' ? '下调金额(元)' : '下调比例(%)'}
|
||||
name={['priceDrop', 'value']}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={priceDropMode === 'fixed' ? undefined : 100}
|
||||
precision={priceDropMode === 'fixed' ? 2 : 0}
|
||||
style={{ width: '100%' }}
|
||||
addonBefore={priceDropMode === 'fixed' ? '¥' : undefined}
|
||||
addonAfter={priceDropMode === 'fixed' ? undefined : '%'}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="order-service-section">
|
||||
<div className="order-service-section-head">
|
||||
<ConsignmentSectionTitle
|
||||
index="10"
|
||||
title="联系方式与游戏凭据"
|
||||
description="打手发布页按这里配置的字段动态渲染;字段为空时该模块不显示"
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
ghost
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={addIntakeField}
|
||||
>
|
||||
添加字段
|
||||
</Button>
|
||||
</div>
|
||||
<Text type="secondary">
|
||||
默认字段与旧发布页一致;手机号字段会校验 11
|
||||
位手机号。对接客服字段被删除后不再校验提成归属。
|
||||
</Text>
|
||||
{intakeDrafts.length === 0 ? (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="还没有收货字段,点击右上角「添加字段」"
|
||||
style={{ margin: '16px 0 4px' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="order-service-field-list" style={{ marginTop: 12 }}>
|
||||
{intakeDrafts.map((field, index) => (
|
||||
<div className="order-service-field-row" key={`${field.key}-${index}`}>
|
||||
<div className="order-service-field-main">
|
||||
<div style={fieldCellStyle}>
|
||||
<Text type="secondary">字段 Key</Text>
|
||||
<Input
|
||||
value={field.key}
|
||||
placeholder="如 sellerPhone"
|
||||
onChange={(event) =>
|
||||
updateIntakeField(index, { key: event.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div style={fieldCellStyle}>
|
||||
<Text type="secondary">显示名称</Text>
|
||||
<Input
|
||||
value={field.label}
|
||||
placeholder="如 联系电话"
|
||||
onChange={(event) =>
|
||||
updateIntakeField(index, { label: event.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div style={fieldCellStyle}>
|
||||
<Text type="secondary">类型</Text>
|
||||
<Select
|
||||
value={field.type}
|
||||
options={INTAKE_FIELD_TYPE_OPTIONS}
|
||||
onChange={(value) => updateIntakeField(index, { type: value })}
|
||||
/>
|
||||
</div>
|
||||
<div style={fieldCellStyle} className="order-service-field-required">
|
||||
<Text type="secondary">必填</Text>
|
||||
<Switch
|
||||
checked={field.required}
|
||||
checkedChildren="必填"
|
||||
unCheckedChildren="选填"
|
||||
onChange={(checked) =>
|
||||
updateIntakeField(index, { required: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="order-service-field-actions">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ArrowUpOutlined />}
|
||||
disabled={index === 0}
|
||||
onClick={() => moveIntakeField(index, -1)}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ArrowDownOutlined />}
|
||||
disabled={index === intakeDrafts.length - 1}
|
||||
onClick={() => moveIntakeField(index, 1)}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => removeIntakeField(index)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="order-service-field-extra">
|
||||
<div style={fieldCellStyle}>
|
||||
<Text type="secondary">占位提示</Text>
|
||||
<Input
|
||||
value={field.placeholder}
|
||||
placeholder="输入框提示文案"
|
||||
onChange={(event) =>
|
||||
updateIntakeField(index, { placeholder: event.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div style={fieldCellStyle}>
|
||||
<Text type="secondary">填写说明</Text>
|
||||
<Input
|
||||
value={field.helpText}
|
||||
placeholder="给打手看的说明"
|
||||
onChange={(event) =>
|
||||
updateIntakeField(index, { helpText: event.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{field.type === 'image' ? (
|
||||
<div style={fieldCellStyle}>
|
||||
<Text type="secondary">最多上传张数</Text>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={12}
|
||||
precision={0}
|
||||
style={{ width: '100%' }}
|
||||
value={field.maxCount}
|
||||
onChange={(value) =>
|
||||
updateIntakeField(index, { maxCount: Number(value || 1) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="order-service-save-bar">
|
||||
<Text type="secondary">保存只更新草稿,发布后才会影响新工单。</Text>
|
||||
<Space>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
EyeOutlined,
|
||||
FileSearchOutlined,
|
||||
ReloadOutlined,
|
||||
RollbackOutlined,
|
||||
StopOutlined,
|
||||
@@ -39,6 +40,7 @@ import { useState } from 'react'
|
||||
|
||||
import ConsignmentEvidenceGallery from '@/components/admin/ConsignmentEvidenceGallery'
|
||||
import ConsignmentPendingMatchModal from '@/components/admin/ConsignmentPendingMatchModal'
|
||||
import ConsignmentMatchLogsModal from '@/components/admin/ConsignmentMatchLogsModal'
|
||||
import {
|
||||
archiveAdminConsignmentInventory,
|
||||
fetchAdminConsignmentContacts,
|
||||
@@ -163,6 +165,7 @@ export default function ConsignmentInventoryPanel() {
|
||||
const [returnItem, setReturnItem] = useState<ConsignmentInventory | null>(null)
|
||||
const [manualMatchItem, setManualMatchItem] = useState<ConsignmentInventory | null>(null)
|
||||
const [pendingMatchOpen, setPendingMatchOpen] = useState(false)
|
||||
const [matchLogsOpen, setMatchLogsOpen] = useState(false)
|
||||
const [returnReason, setReturnReason] = useState<string>('')
|
||||
|
||||
const [editForm] = Form.useForm()
|
||||
@@ -891,6 +894,10 @@ export default function ConsignmentInventoryPanel() {
|
||||
一键查询待匹配订单
|
||||
</Button>
|
||||
|
||||
<Button icon={<FileSearchOutlined />} onClick={() => setMatchLogsOpen(true)}>
|
||||
匹配日志
|
||||
</Button>
|
||||
|
||||
<Button icon={<ReloadOutlined />} onClick={refresh}>
|
||||
刷新
|
||||
</Button>
|
||||
@@ -1409,6 +1416,8 @@ export default function ConsignmentInventoryPanel() {
|
||||
open={pendingMatchOpen}
|
||||
onClose={() => setPendingMatchOpen(false)}
|
||||
/>
|
||||
|
||||
<ConsignmentMatchLogsModal open={matchLogsOpen} onClose={() => setMatchLogsOpen(false)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -897,15 +897,53 @@ export default function ConsignmentSubmissionsPanel() {
|
||||
¥{(viewingSubmission.askingPriceFen / 100).toFixed(2)}
|
||||
</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="卖家微信号">
|
||||
<Text copyable>{viewingSubmission.contactInfo}</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="卖家电话">
|
||||
<Text copyable>{viewingSubmission.sellerPhone || '-'}</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="卖家游戏ID" span={2}>
|
||||
<Text copyable>{viewingSubmission.sellerGameId || '-'}</Text>
|
||||
</Descriptions.Item>
|
||||
{viewingSubmission.intakeFields && viewingSubmission.intakeFields.length > 0 ? (
|
||||
<>
|
||||
{viewingSubmission.intakeFields.map((field) => {
|
||||
const raw = viewingSubmission.intakeValues?.[field.key]
|
||||
if (field.type === 'image') {
|
||||
const images = Array.isArray(raw)
|
||||
? raw.filter((item): item is string => typeof item === 'string')
|
||||
: []
|
||||
return (
|
||||
<Descriptions.Item label={field.label} span={2} key={field.key}>
|
||||
<ConsignmentEvidenceGallery
|
||||
images={images}
|
||||
emptyText="未上传"
|
||||
max={8}
|
||||
size={80}
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
)
|
||||
}
|
||||
const display =
|
||||
raw === undefined || raw === null || String(raw).trim() === ''
|
||||
? '-'
|
||||
: String(raw)
|
||||
return (
|
||||
<Descriptions.Item
|
||||
label={field.label}
|
||||
span={field.type === 'textarea' ? 2 : 1}
|
||||
key={field.key}
|
||||
>
|
||||
<Text copyable={field.type !== 'textarea'}>{display}</Text>
|
||||
</Descriptions.Item>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Descriptions.Item label="卖家微信号">
|
||||
<Text copyable>{viewingSubmission.contactInfo}</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="卖家电话">
|
||||
<Text copyable>{viewingSubmission.sellerPhone || '-'}</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="卖家游戏ID" span={2}>
|
||||
<Text copyable>{viewingSubmission.sellerGameId || '-'}</Text>
|
||||
</Descriptions.Item>
|
||||
</>
|
||||
)}
|
||||
<Descriptions.Item label="关联接单打手">
|
||||
{viewingSubmission.workerId ? (
|
||||
<Tag color="blue">
|
||||
|
||||
@@ -55,12 +55,13 @@ import {
|
||||
} from '@/services/worker'
|
||||
import type { WorkerConsignmentRecord } from '@/services/worker'
|
||||
import type {
|
||||
ConsignmentIntakeField,
|
||||
ConsignmentSpecField,
|
||||
ConsignmentSpecValues,
|
||||
WorkerConsignmentProfile,
|
||||
} from '@/types/consignment'
|
||||
import type { UploadedFile } from '@/types/worker-platform'
|
||||
import { buildSpecItemName } from '@/utils/consignment-spec'
|
||||
import { DEFAULT_CONSIGNMENT_INTAKE_FIELDS, buildSpecItemName } from '@/utils/consignment-spec'
|
||||
import { formatConsignmentContactSupporter } from '@/utils/consignment-contact'
|
||||
import { formatGiftCooldownRemaining } from '@/utils/consignment-cooldown'
|
||||
import { canWorkerPublishConsignment } from '@/utils/consignment-whitelist'
|
||||
@@ -151,8 +152,9 @@ export default function WorkerConsignmentPage() {
|
||||
}
|
||||
}
|
||||
}
|
||||
const [screenshots, setScreenshots] = useState<UploadedFile[]>([])
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
/** 收货信息图片字段:key -> 已上传文件 */
|
||||
const [intakeImages, setIntakeImages] = useState<Record<string, UploadedFile[]>>({})
|
||||
const [evidenceItem, setEvidenceItem] = useState<WorkerConsignmentRecord | null>(null)
|
||||
const [evidenceFiles, setEvidenceFiles] = useState<UploadedFile[]>([])
|
||||
const [evidenceNote, setEvidenceNote] = useState('')
|
||||
@@ -203,9 +205,16 @@ export default function WorkerConsignmentPage() {
|
||||
consignmentProfiles[0] ||
|
||||
null
|
||||
const selectedSpecs: ConsignmentSpecField[] = selectedProfile?.config.specs ?? []
|
||||
const intakeFields: ConsignmentIntakeField[] =
|
||||
selectedProfile?.config.intake?.fields ?? DEFAULT_CONSIGNMENT_INTAKE_FIELDS
|
||||
useEffect(() => {
|
||||
if (selectedProfileId === null && consignmentProfiles.length > 0) {
|
||||
setSelectedProfileId(consignmentProfiles[0]!.profileId)
|
||||
// 默认苏尔南手枪;没有该分类时退回第一个已发布配置。
|
||||
const preferred =
|
||||
consignmentProfiles.find((profile) =>
|
||||
/苏尔南|苏里南/.test(`${profile.categoryName}${profile.tabTitle}${profile.name}`),
|
||||
) || consignmentProfiles[0]
|
||||
if (preferred) setSelectedProfileId(preferred.profileId)
|
||||
}
|
||||
}, [consignmentProfiles, selectedProfileId])
|
||||
|
||||
@@ -339,6 +348,18 @@ export default function WorkerConsignmentPage() {
|
||||
return values
|
||||
}
|
||||
|
||||
function handleProfileChange(profileId: number) {
|
||||
setSelectedProfileId(profileId)
|
||||
form.resetFields()
|
||||
setPriceState({ status: 'idle' })
|
||||
lastPriceQueryKeyRef.current = ''
|
||||
setIntakeImages({})
|
||||
if (worker?.phone) form.setFieldsValue({ sellerPhone: worker.phone })
|
||||
if (firstContactSupporterId != null) {
|
||||
form.setFieldsValue({ contactSupporterId: firstContactSupporterId })
|
||||
}
|
||||
}
|
||||
|
||||
function handleFormValuesChange(changed: Record<string, unknown>) {
|
||||
// 钻级改变时:璀璨数不能超过钻石等级(如 5钻调成 2钻,原 5璀自动收敛到 2璀)
|
||||
if (changed && changed.diamondLevel !== undefined) {
|
||||
@@ -417,30 +438,67 @@ export default function WorkerConsignmentPage() {
|
||||
return
|
||||
}
|
||||
|
||||
if (contactSupporters.length === 0) {
|
||||
message.error('暂无可选的对接客服,请联系管理员在「配置 - 对接客服」中添加后再发布')
|
||||
return
|
||||
// 联系方式与游戏凭据完全按寄卖配置动态收集;图片字段用独立 state 保存。
|
||||
const activeIntakeFields =
|
||||
selectedProfile?.config.intake?.fields ?? DEFAULT_CONSIGNMENT_INTAKE_FIELDS
|
||||
const intakeValues: Record<string, unknown> = {}
|
||||
for (const field of activeIntakeFields) {
|
||||
if (field.type === 'image') {
|
||||
const urls = (intakeImages[field.key] || []).map((file) => file.url).filter(Boolean)
|
||||
if (urls.length > 0) {
|
||||
intakeValues[field.key] = urls
|
||||
} else if (field.required) {
|
||||
message.error(`请上传「${field.label}」`)
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
const raw = values[field.key]
|
||||
if (raw === undefined || raw === null || String(raw).trim() === '') {
|
||||
if (field.required) {
|
||||
message.error(`请填写「${field.label}」`)
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
intakeValues[field.key] =
|
||||
field.type === 'number' || field.type === 'contact_supporter'
|
||||
? Number(raw)
|
||||
: String(raw).trim()
|
||||
}
|
||||
if (values.contactSupporterId == null) {
|
||||
message.error('请选择对接客服')
|
||||
return
|
||||
const phoneField = activeIntakeFields.find((field) => field.type === 'phone')
|
||||
if (phoneField) {
|
||||
const phoneValue = String(intakeValues[phoneField.key] || '').trim()
|
||||
if (phoneValue && !/^1[3-9]\d{9}$/.test(phoneValue)) {
|
||||
message.error(`「${phoneField.label}」请填写正确的 11 位手机号`)
|
||||
return
|
||||
}
|
||||
}
|
||||
const sellerContact = String(intakeValues.sellerContact ?? '').trim()
|
||||
const sellerPhone = String(intakeValues.sellerPhone ?? worker?.phone ?? '').trim()
|
||||
const characterName = String(intakeValues.characterName ?? '').trim()
|
||||
const characterId = String(intakeValues.characterId ?? '').trim()
|
||||
const sellerNote = String(intakeValues.sellerNote ?? '').trim()
|
||||
const publishScreenshots = Array.isArray(intakeValues.screenshots)
|
||||
? (intakeValues.screenshots as string[])
|
||||
: []
|
||||
|
||||
const urls = screenshots.map((f) => f.url).filter(Boolean)
|
||||
if (urls.length === 0) {
|
||||
message.error('请上传游戏主页截图以供核验')
|
||||
return
|
||||
}
|
||||
|
||||
// 寄售价:按后台配置带出系统建议价;允许改价时可下调,但不能高于建议价。
|
||||
// 寄售价:必须命中接单模板价;允许改价时可下调,但不能高于建议价。
|
||||
const minPriceFen = Math.max(0, selectedProfile?.config.pricing.minPriceFen || 0)
|
||||
const suggestedPriceFen = priceState.status === 'matched' ? priceState.fen : 0
|
||||
const capPriceFen = priceState.status === 'matched' ? priceState.capFen : 0
|
||||
let askingPriceFen = suggestedPriceFen
|
||||
if (priceState.status === 'loading') {
|
||||
message.warning('正在计算系统建议价,请稍候')
|
||||
return
|
||||
}
|
||||
if (priceState.status !== 'matched' || priceState.fen <= 0) {
|
||||
message.error('该规格暂无模板价,暂不能寄卖,请联系客服补充模板价格')
|
||||
return
|
||||
}
|
||||
const capPriceFen = priceState.capFen > 0 ? priceState.capFen : priceState.fen
|
||||
let askingPriceFen = priceState.fen
|
||||
if (manualPriceAllowed) {
|
||||
const submittedPriceFen = Math.max(0, Math.round(Number(values.askingPrice || 0) * 100))
|
||||
if (submittedPriceFen > 0) {
|
||||
if (capPriceFen > 0 && submittedPriceFen > capPriceFen) {
|
||||
if (submittedPriceFen > capPriceFen) {
|
||||
message.error(`寄售价格不能高于系统建议价 ¥${(capPriceFen / 100).toFixed(2)}`)
|
||||
return
|
||||
}
|
||||
@@ -449,24 +507,12 @@ export default function WorkerConsignmentPage() {
|
||||
return
|
||||
}
|
||||
askingPriceFen = submittedPriceFen
|
||||
} else if (suggestedPriceFen <= 0) {
|
||||
message.error('该规格暂无价格,请填写寄售价格')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if (priceState.status === 'loading') {
|
||||
message.warning('正在计算系统建议价,请稍候')
|
||||
return
|
||||
}
|
||||
if (priceState.status !== 'matched') {
|
||||
message.error('该规格暂无价格,请联系客服补充配置后再提报')
|
||||
return
|
||||
}
|
||||
if (askingPriceFen < 100) {
|
||||
message.error('寄售价格不能少于1元')
|
||||
return
|
||||
}
|
||||
}
|
||||
if (askingPriceFen < 100) {
|
||||
message.error('寄售价格不能少于1元')
|
||||
return
|
||||
}
|
||||
|
||||
const specValues = collectSpecValues()
|
||||
const itemName =
|
||||
@@ -491,14 +537,17 @@ export default function WorkerConsignmentPage() {
|
||||
diamondLevel: Number(specValues.diamondLevel || 1),
|
||||
brillianceCount: Number(specValues.brillianceCount || 0),
|
||||
askingPriceFen,
|
||||
sellerContact: values.sellerContact,
|
||||
sellerPhone: values.sellerPhone || worker?.phone || '',
|
||||
characterName: values.characterName,
|
||||
characterId: values.characterId,
|
||||
screenshotsJson: urls,
|
||||
sellerNote: values.sellerNote,
|
||||
sellerContact,
|
||||
sellerPhone,
|
||||
characterName,
|
||||
characterId,
|
||||
screenshotsJson: publishScreenshots,
|
||||
sellerNote,
|
||||
intakeValues,
|
||||
depositAmountFen: publishDepositFen,
|
||||
contactSupporterId: Number(values.contactSupporterId),
|
||||
contactSupporterId: intakeValues.contactSupporterId
|
||||
? Number(intakeValues.contactSupporterId)
|
||||
: null,
|
||||
})
|
||||
|
||||
message.success(`寄卖提报成功,已暂扣履约押金 ¥${publishDepositYuan.toFixed(0)}`)
|
||||
@@ -508,7 +557,7 @@ export default function WorkerConsignmentPage() {
|
||||
}
|
||||
setPriceState({ status: 'idle' })
|
||||
lastPriceQueryKeyRef.current = ''
|
||||
setScreenshots([])
|
||||
setIntakeImages({})
|
||||
await Promise.all([myConsignmentsQuery.refetch(), profileQuery.refetch()])
|
||||
setRecordFilter('reviewing')
|
||||
setActiveTab('records')
|
||||
@@ -654,6 +703,31 @@ export default function WorkerConsignmentPage() {
|
||||
onFinish={handlePublishSubmit}
|
||||
onValuesChange={handleFormValuesChange}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
flexWrap: 'wrap',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Typography.Text strong>寄卖分类</Typography.Text>
|
||||
<Select
|
||||
value={selectedProfile?.profileId}
|
||||
style={{ minWidth: 220 }}
|
||||
placeholder={consignmentProfiles.length === 0 ? '暂无可发布的寄卖分类' : '请选择寄卖分类'}
|
||||
options={consignmentProfiles.map((profile) => ({
|
||||
value: profile.profileId,
|
||||
label: profile.tabTitle || profile.categoryName || profile.name,
|
||||
}))}
|
||||
onChange={(value) => handleProfileChange(Number(value))}
|
||||
/>
|
||||
{selectedProfile?.tabDescription ? (
|
||||
<Typography.Text type="secondary">{selectedProfile.tabDescription}</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Alert
|
||||
type={canPublish ? 'info' : 'warning'}
|
||||
showIcon
|
||||
@@ -846,84 +920,94 @@ export default function WorkerConsignmentPage() {
|
||||
联系方式与游戏凭据
|
||||
</Divider>
|
||||
|
||||
<Row gutter={[12, 0]}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item
|
||||
name="contactSupporterId"
|
||||
label="对接客服"
|
||||
rules={[{ required: true, message: '请选择对接客服' }]}
|
||||
extra={
|
||||
contactSupporters.length === 0
|
||||
? '暂无可选客服,请联系管理员配置'
|
||||
: '用于后续提成归属与联系'
|
||||
{intakeFields.length === 0 ? (
|
||||
<Alert type="info" showIcon message="当前寄卖配置未启用收货信息字段。" />
|
||||
) : (
|
||||
<Row gutter={[12, 0]}>
|
||||
{intakeFields.map((field) => {
|
||||
if (field.type === 'image') {
|
||||
return (
|
||||
<Col span={24} key={field.key}>
|
||||
<Form.Item
|
||||
label={field.label}
|
||||
required={field.required}
|
||||
extra={field.helpText || undefined}
|
||||
>
|
||||
<ImageUpload
|
||||
value={intakeImages[field.key] || []}
|
||||
onChange={(files) =>
|
||||
setIntakeImages((current) => ({ ...current, [field.key]: files }))
|
||||
}
|
||||
scene="consignment_screenshot"
|
||||
scope="worker"
|
||||
maxCount={field.maxCount || 4}
|
||||
showPasteHint={!isMobile}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
>
|
||||
<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>
|
||||
const rules = [
|
||||
...(field.required ? [{ required: true, message: `请填写${field.label}` }] : []),
|
||||
...(field.type === 'phone'
|
||||
? [{ pattern: /^1[3-9]\d{9}$/, message: '请填写正确的 11 位手机号' }]
|
||||
: []),
|
||||
]
|
||||
return (
|
||||
<Col xs={24} sm={12} key={field.key}>
|
||||
<Form.Item
|
||||
name={field.key}
|
||||
label={field.label}
|
||||
rules={rules}
|
||||
extra={field.helpText || undefined}
|
||||
>
|
||||
{field.type === 'contact_supporter' ? (
|
||||
<Select
|
||||
placeholder={field.placeholder || '请选择对接客服'}
|
||||
options={contactSupporters.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.name,
|
||||
}))}
|
||||
notFoundContent="暂无启用中的对接客服"
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
) : field.type === 'textarea' ? (
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
placeholder={field.placeholder || undefined}
|
||||
maxLength={2000}
|
||||
/>
|
||||
) : field.type === 'number' ? (
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={0}
|
||||
placeholder={field.placeholder || undefined}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
placeholder={field.placeholder || undefined}
|
||||
maxLength={field.type === 'phone' ? 11 : 200}
|
||||
/>
|
||||
)}
|
||||
</Form.Item>
|
||||
</Col>
|
||||
)
|
||||
})}
|
||||
{intakeFields.some((field) => field.type === 'contact_supporter') &&
|
||||
!consignmentContactsQuery.isLoading &&
|
||||
contactSupporters.length === 0 ? (
|
||||
<Col span={24}>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="暂无可选的对接客服,请联系管理员在「配置 - 对接客服」中添加后再发布"
|
||||
/>
|
||||
</Col>
|
||||
) : null}
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<div className="worker-consignment-actions">
|
||||
<Button
|
||||
@@ -945,28 +1029,17 @@ export default function WorkerConsignmentPage() {
|
||||
</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,
|
||||
},
|
||||
]
|
||||
const publishTabs = [
|
||||
{
|
||||
key: 'publish',
|
||||
label: (
|
||||
<span>
|
||||
<DollarCircleOutlined /> 发布寄卖
|
||||
</span>
|
||||
),
|
||||
children: publishTabContent,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="page-stack worker-consignment-page">
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
WorkOrderInventoryMatchCheck,
|
||||
WorkOrderInventoryMatchOptions,
|
||||
} from '@/types/consignment'
|
||||
import type { ConsignmentMatchLogsResponse } from '@/types/consignment-match-log'
|
||||
|
||||
// --- 分类级寄卖配置 ---
|
||||
|
||||
@@ -63,6 +64,23 @@ export function matchAdminConsignmentWorkOrder(payload: {
|
||||
)
|
||||
}
|
||||
|
||||
/** 匹配日志:自动/手动/重新匹配的每次尝试(含失败/跳过原因)。 */
|
||||
export function fetchAdminConsignmentMatchLogs(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
profileId?: number
|
||||
triggerType?: string
|
||||
result?: string
|
||||
keyword?: string
|
||||
from?: string
|
||||
to?: string
|
||||
}) {
|
||||
return apiGet<ConsignmentMatchLogsResponse>(
|
||||
'/api/v1/admin/worker-platform/consignment/match-logs',
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
// --- 对接客服名单 ---
|
||||
|
||||
export function fetchAdminConsignmentContacts(params?: { keyword?: string; enabled?: string }) {
|
||||
|
||||
@@ -387,15 +387,17 @@ export type WorkerConsignmentPublishPayload = {
|
||||
diamondLevel?: number
|
||||
brillianceCount?: number
|
||||
askingPriceFen: number
|
||||
sellerContact: string
|
||||
sellerContact?: string
|
||||
sellerPhone?: string
|
||||
characterName: string
|
||||
characterId: string
|
||||
screenshotsJson: string[]
|
||||
characterName?: string
|
||||
characterId?: string
|
||||
screenshotsJson?: string[]
|
||||
sellerNote?: string
|
||||
/** 动态收货信息(联系方式与游戏凭据)字段值 */
|
||||
intakeValues?: Record<string, unknown>
|
||||
depositAmountFen?: number
|
||||
/** 对接客服名单 id(必填,发布时选定) */
|
||||
contactSupporterId: number
|
||||
/** 对接客服名单 id;配置里删除该字段时可为空 */
|
||||
contactSupporterId?: number | null
|
||||
}
|
||||
|
||||
export type WorkerConsignmentItem = {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 寄卖匹配日志(后台「匹配日志」抽屉)
|
||||
* 记录自动/手动/重新匹配的每次尝试,用于留痕和问题分析。
|
||||
*/
|
||||
export type ConsignmentMatchLogTrigger = 'auto' | 'manual' | 'rematch'
|
||||
export type ConsignmentMatchLogResult = 'matched' | 'failed' | 'skipped'
|
||||
|
||||
export type ConsignmentMatchLogItem = {
|
||||
logId: number
|
||||
workOrderId: number | null
|
||||
workOrderNo: string
|
||||
profileId: number | null
|
||||
categoryId: number | null
|
||||
triggerType: ConsignmentMatchLogTrigger
|
||||
result: ConsignmentMatchLogResult
|
||||
reasonCode: string
|
||||
reasonText: string
|
||||
candidateCount: number
|
||||
matchedCount: number
|
||||
actorType: string
|
||||
actorId: string
|
||||
durationMs: number
|
||||
payload: Record<string, unknown>
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type ConsignmentMatchLogsResponse = {
|
||||
items: ConsignmentMatchLogItem[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
@@ -53,6 +53,28 @@ export type ConsignmentSpecField = {
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
/** 联系方式与游戏凭据:按寄卖配置动态渲染的收货信息字段 */
|
||||
export type ConsignmentIntakeFieldType =
|
||||
| 'text'
|
||||
| 'textarea'
|
||||
| 'phone'
|
||||
| 'number'
|
||||
| 'image'
|
||||
| 'contact_supporter'
|
||||
|
||||
export type ConsignmentIntakeField = {
|
||||
key: string
|
||||
label: string
|
||||
type: ConsignmentIntakeFieldType
|
||||
required: boolean
|
||||
placeholder: string
|
||||
helpText: string
|
||||
maxCount: number
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
export type ConsignmentIntakeValues = Record<string, string | number | string[]>
|
||||
|
||||
/**
|
||||
* 客服列表 / 详情接口额外返回的寄卖凭证字段(提报列表与库存列表都有)。
|
||||
*
|
||||
@@ -216,6 +238,9 @@ export type ConsignmentSubmission = ConsignmentEvidenceFields &
|
||||
specValues?: ConsignmentSpecValues
|
||||
specText?: string
|
||||
specs?: ConsignmentSpecField[]
|
||||
/** 收货信息动态字段与提交值(按发布快照) */
|
||||
intakeFields?: ConsignmentIntakeField[]
|
||||
intakeValues?: Record<string, unknown>
|
||||
priceCapFen?: number
|
||||
quantity?: number
|
||||
requestedSalePriceFen?: number
|
||||
@@ -363,11 +388,12 @@ export type ConsignmentProfileConfig = {
|
||||
/** 物品名模板,支持 {system}/{series}/{category} 等占位符 */
|
||||
nameTemplate: string
|
||||
pricing: {
|
||||
/** 接单模板价打折 / 固定价格 */
|
||||
priceType: 'template_discount' | 'fixed'
|
||||
/** 接单模板价打折 / 模板价固定下调 */
|
||||
priceType: 'template_discount' | 'template_minus'
|
||||
/** 几折,0.1 - 10,例如 9.5 */
|
||||
discount: number
|
||||
fixedPriceFen: number
|
||||
/** 固定下调金额(分):售价 = 模板价 - minusFen */
|
||||
minusFen: number
|
||||
allowWorkerPrice: boolean
|
||||
minPriceFen: number
|
||||
missingPricePolicy: 'block' | 'manual'
|
||||
@@ -381,19 +407,26 @@ export type ConsignmentProfileConfig = {
|
||||
serviceFee: { enabled: boolean; mode: 'percent' | 'fixed'; percent: number; amountFen: number }
|
||||
inventoryLimit: { enabled: boolean; maxActiveQuantity: number }
|
||||
matching: {
|
||||
mode: 'manual' | 'auto'
|
||||
autoMatchEnabled: boolean
|
||||
/** off = 完全关闭匹配;manual = 人工匹配;auto = 自动调度 */
|
||||
mode: 'off' | 'manual' | 'auto'
|
||||
exactOnly: boolean
|
||||
preferLowReward: boolean
|
||||
}
|
||||
internalMatch: { delayMinutes: number; fallbackToHall: boolean }
|
||||
timeout: { minutes: number; policy: 'reopen' | 'cancel_release' | 'cancel_deduct' }
|
||||
timeout: {
|
||||
minutes: number
|
||||
policy: 'reopen' | 'cancel_release' | 'cancel_deduct' | 'rematch_release' | 'rematch_deduct'
|
||||
}
|
||||
priceDrop: {
|
||||
enabled: boolean
|
||||
hours: number
|
||||
mode: 'percent' | 'fixed'
|
||||
value: number
|
||||
}
|
||||
/** 联系方式与游戏凭据动态字段 */
|
||||
intake: {
|
||||
fields: ConsignmentIntakeField[]
|
||||
}
|
||||
}
|
||||
|
||||
export type ConsignmentPricingConfig = ConsignmentProfileConfig['pricing']
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type {
|
||||
ConsignmentIntakeField,
|
||||
ConsignmentIntakeFieldType,
|
||||
ConsignmentPricingConfig,
|
||||
ConsignmentSpecField,
|
||||
ConsignmentSpecFieldType,
|
||||
@@ -38,6 +40,96 @@ export function isOptionSpecField(field: Pick<ConsignmentSpecField, 'type'>): bo
|
||||
return field.type === 'select' || field.type === 'radio'
|
||||
}
|
||||
|
||||
export const INTAKE_FIELD_TYPE_OPTIONS: Array<{
|
||||
label: string
|
||||
value: ConsignmentIntakeFieldType
|
||||
}> = [
|
||||
{ label: '文本', value: 'text' },
|
||||
{ label: '多行文本', value: 'textarea' },
|
||||
{ label: '手机号', value: 'phone' },
|
||||
{ label: '数字', value: 'number' },
|
||||
{ label: '图片', value: 'image' },
|
||||
{ label: '对接客服', value: 'contact_supporter' },
|
||||
]
|
||||
|
||||
export function intakeFieldTypeLabel(type: ConsignmentIntakeFieldType): string {
|
||||
return INTAKE_FIELD_TYPE_OPTIONS.find((item) => item.value === type)?.label || '文本'
|
||||
}
|
||||
|
||||
/** 与后端 DEFAULT_CONSIGNMENT_INTAKE_FIELDS 保持一致。 */
|
||||
export const DEFAULT_CONSIGNMENT_INTAKE_FIELDS: ConsignmentIntakeField[] = [
|
||||
{
|
||||
key: 'contactSupporterId',
|
||||
label: '对接客服',
|
||||
type: 'contact_supporter',
|
||||
required: true,
|
||||
placeholder: '请选择对接客服',
|
||||
helpText: '用于后续提成归属与联系',
|
||||
maxCount: 0,
|
||||
sortOrder: 10,
|
||||
},
|
||||
{
|
||||
key: 'sellerContact',
|
||||
label: '微信号 / QQ号',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: '方便派单与紧急联系',
|
||||
helpText: '',
|
||||
maxCount: 0,
|
||||
sortOrder: 20,
|
||||
},
|
||||
{
|
||||
key: 'sellerPhone',
|
||||
label: '联系电话',
|
||||
type: 'phone',
|
||||
required: true,
|
||||
placeholder: '手机号',
|
||||
helpText: '默认使用当前接单账号绑定手机号',
|
||||
maxCount: 0,
|
||||
sortOrder: 30,
|
||||
},
|
||||
{
|
||||
key: 'characterName',
|
||||
label: '游戏内角色名',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: '局内加好友丢枪用',
|
||||
helpText: '',
|
||||
maxCount: 0,
|
||||
sortOrder: 40,
|
||||
},
|
||||
{
|
||||
key: 'characterId',
|
||||
label: '角色数字ID',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: '如 1089238472',
|
||||
helpText: '',
|
||||
maxCount: 0,
|
||||
sortOrder: 50,
|
||||
},
|
||||
{
|
||||
key: 'screenshots',
|
||||
label: '游戏主页截图',
|
||||
type: 'image',
|
||||
required: true,
|
||||
placeholder: '',
|
||||
helpText: '须包含角色名、ID 及仓库/手枪详情,最多 4 张',
|
||||
maxCount: 4,
|
||||
sortOrder: 60,
|
||||
},
|
||||
{
|
||||
key: 'sellerNote',
|
||||
label: '其他留言与补充',
|
||||
type: 'textarea',
|
||||
required: false,
|
||||
placeholder: '配件、子弹、涂装或在线时段等(选填)',
|
||||
helpText: '',
|
||||
maxCount: 0,
|
||||
sortOrder: 70,
|
||||
},
|
||||
]
|
||||
|
||||
export function formatSpecValue(
|
||||
field: ConsignmentSpecField,
|
||||
value: unknown,
|
||||
@@ -122,15 +214,18 @@ export function computeSpecPrice(
|
||||
const breakdown: Array<{ label: string; amountFen: number }> = []
|
||||
let priceFen = 0
|
||||
|
||||
if (pricing.priceType === 'fixed') {
|
||||
priceFen = Math.max(0, Math.round(Number(pricing.fixedPriceFen || 0)))
|
||||
if (priceFen > 0) breakdown.push({ label: '固定价格', amountFen: priceFen })
|
||||
} else if (template > 0) {
|
||||
const discount = Math.min(10, Math.max(0.1, Number(pricing.discount || 10)))
|
||||
priceFen = Math.round((template * discount) / 10)
|
||||
if (template > 0) {
|
||||
breakdown.push({ label: '接单模板价', amountFen: template })
|
||||
if (discount !== 10) {
|
||||
breakdown.push({ label: `${discount} 折`, amountFen: priceFen - template })
|
||||
if (pricing.priceType === 'template_minus') {
|
||||
const minusFen = Math.max(0, Math.round(Number(pricing.minusFen || 0)))
|
||||
priceFen = Math.max(0, template - minusFen)
|
||||
if (minusFen > 0) breakdown.push({ label: '固定下调', amountFen: -minusFen })
|
||||
} else {
|
||||
const discount = Math.min(10, Math.max(0.1, Number(pricing.discount || 10)))
|
||||
priceFen = Math.round((template * discount) / 10)
|
||||
if (discount !== 10) {
|
||||
breakdown.push({ label: `${discount} 折`, amountFen: priceFen - template })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,9 +33,10 @@ test('寄卖配置面板:必须覆盖保证金/服务费/数量限制/匹配/
|
||||
'开启服务费',
|
||||
'开启单人寄售上限',
|
||||
'匹配模式',
|
||||
'自动匹配开关',
|
||||
'关闭匹配',
|
||||
'内部匹配等待(分钟)',
|
||||
'代练超时(分钟)',
|
||||
'代练超时(小时)',
|
||||
'重新匹配并退还押金',
|
||||
'开启降价提示',
|
||||
]) {
|
||||
assert.ok(panel.includes(label), `寄卖配置面板缺少「${label}」`)
|
||||
@@ -75,9 +76,11 @@ test('寄卖配置面板:订单服务风格分类树、独立分区与价格
|
||||
'服务费',
|
||||
'库存数量限制',
|
||||
'匹配设置',
|
||||
'超时与降价提示',
|
||||
'超时设置',
|
||||
'降价提示',
|
||||
'联系方式与游戏凭据',
|
||||
'接单模板价打折',
|
||||
'固定价格',
|
||||
'模板价固定下调',
|
||||
'模板折扣',
|
||||
'物品名模板',
|
||||
]) {
|
||||
|
||||
Reference in New Issue
Block a user