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
@@ -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 }) {
+8 -6
View File
@@ -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
}
+39 -6
View File
@@ -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']
+103 -8
View File
@@ -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 })
}
}
}