feat(consignment): 寄卖配置价格/匹配/超时/收货字段全面改造
- 价格:固定价格改为模板价固定下调,移除绝对固定价;没有模板价时统一阻止发布 - 配置面板:按下拉选项动态显隐,超时与降价提示拆成两张卡片,超时单位改为小时(默认 0.5) - 匹配:新增关闭匹配并设为默认,移除自动匹配开关;关闭后新单直接进大厅 - 超时:新增重新匹配退/扣押金,回内部匹配池并排除原卖家与原库存(迁移 147) - 日志:新增 consignment_match_logs,自动/手动匹配成功、失败、跳过统一留痕,后台新增匹配日志抽屉 - 打手端:顶部分类下拉替换多 tab,默认苏尔南手枪;联系方式与游戏凭据改为动态字段(默认 7 项,手机号必填) - 降价提示默认开启、默认 24 小时
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user