feat(consignment): 寄卖配置价格/匹配/超时/收货字段全面改造

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