feat(consignment): 寄卖规格与价格动态配置重构
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { App, Alert, Button, Card, Empty, Modal, Space, Tag, Typography } from 'antd'
|
||||
import { App, Alert, Button, Card, Empty, Input, Modal, Space, Tag, Typography } from 'antd'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
@@ -27,6 +27,7 @@ export default function ConsignmentWorkOrderMatchModal({
|
||||
const { message } = App.useApp()
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([])
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [nonExactReason, setNonExactReason] = useState('')
|
||||
|
||||
const candidatesQuery = useQuery({
|
||||
queryKey: ['admin-consignment-match-candidates', workOrderId],
|
||||
@@ -40,13 +41,24 @@ export default function ConsignmentWorkOrderMatchModal({
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSelectedIds([])
|
||||
setNonExactReason('')
|
||||
return
|
||||
}
|
||||
if (options.length === 0) return
|
||||
// 默认按缺口选中价格最低的几条,客服可自行调整。
|
||||
setSelectedIds(options.slice(0, needed).map((item) => item.inventoryId))
|
||||
// 默认只选精确匹配;非精确库存必须人工勾选并填写原因。
|
||||
const exactOptions = options.filter((item) => (item.matchType || 'exact') === 'exact')
|
||||
setSelectedIds(exactOptions.slice(0, needed).map((item) => item.inventoryId))
|
||||
}, [open, options, needed])
|
||||
|
||||
const nonExactSelected = useMemo(
|
||||
() =>
|
||||
selectedIds.some((id) => {
|
||||
const item = options.find((option) => option.inventoryId === id)
|
||||
return Boolean(item?.matchType && item.matchType !== 'exact')
|
||||
}),
|
||||
[options, selectedIds],
|
||||
)
|
||||
|
||||
const totalPrice = useMemo(
|
||||
() =>
|
||||
selectedIds.reduce((sum, id) => {
|
||||
@@ -69,7 +81,7 @@ export default function ConsignmentWorkOrderMatchModal({
|
||||
<Button
|
||||
type="primary"
|
||||
loading={submitting}
|
||||
disabled={selectedIds.length === 0}
|
||||
disabled={selectedIds.length === 0 || (nonExactSelected && !nonExactReason.trim())}
|
||||
onClick={async () => {
|
||||
if (!workOrderId) return
|
||||
setSubmitting(true)
|
||||
@@ -77,6 +89,8 @@ export default function ConsignmentWorkOrderMatchModal({
|
||||
const result = await matchAdminConsignmentWorkOrder({
|
||||
workOrderId,
|
||||
inventoryIds: selectedIds,
|
||||
allowNonExact: nonExactSelected,
|
||||
reason: nonExactSelected ? nonExactReason.trim() : undefined,
|
||||
})
|
||||
message.success(
|
||||
result.data?.matched
|
||||
@@ -99,7 +113,7 @@ export default function ConsignmentWorkOrderMatchModal({
|
||||
>
|
||||
{candidatesQuery.isLoading ? <Card loading /> : null}
|
||||
{!candidatesQuery.isLoading && options.length === 0 ? (
|
||||
<Empty description="没有精确匹配的在库寄卖库存" />
|
||||
<Empty description="没有同配置的在库寄卖库存" />
|
||||
) : null}
|
||||
{options.length > 0 ? (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
@@ -107,7 +121,7 @@ export default function ConsignmentWorkOrderMatchModal({
|
||||
type="info"
|
||||
showIcon
|
||||
message={`该工单${data?.sharingEnabled ? `拼单 ${data.totalQuantity} 份,已匹配 ${data.activeQuantity} 份` : '为整单'},本次还需 ${needed} 条库存。`}
|
||||
description="自动匹配只做精确规格;手动匹配可选多条,拼单工单每选一条生成一个份额。"
|
||||
description="自动匹配只做精确规格;手动匹配可选多条,拼单工单每选一条生成一个份额。选择非精确库存必须填写原因。"
|
||||
/>
|
||||
<PercentTable<ConsignmentMatchCandidate>
|
||||
rowKey="inventoryId"
|
||||
@@ -133,8 +147,30 @@ export default function ConsignmentWorkOrderMatchModal({
|
||||
{
|
||||
title: '规格',
|
||||
render: (_, item) =>
|
||||
item.specText ||
|
||||
`${item.system}/${item.serverZone}/${item.series}/${item.diamondLevel}钻/${item.brillianceCount}璀`,
|
||||
},
|
||||
{
|
||||
title: '匹配',
|
||||
width: 90,
|
||||
render: (_, item) => {
|
||||
const type = item.matchType || 'exact'
|
||||
const label =
|
||||
type === 'exact'
|
||||
? '精确'
|
||||
: type === 'partial'
|
||||
? '高配'
|
||||
: type === 'unknown'
|
||||
? '未知'
|
||||
: '冲突'
|
||||
const color = type === 'exact' ? 'green' : type === 'partial' ? 'orange' : 'red'
|
||||
return (
|
||||
<Tag color={color} title={item.matchReasons?.join('、') || ''}>
|
||||
{label}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '库存',
|
||||
width: 90,
|
||||
@@ -151,6 +187,21 @@ export default function ConsignmentWorkOrderMatchModal({
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{nonExactSelected ? (
|
||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="已选择非精确库存:请确认规格差异,并填写人工匹配原因。"
|
||||
/>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
value={nonExactReason}
|
||||
onChange={(event) => setNonExactReason(event.target.value)}
|
||||
placeholder="例如:订单规格未写全,客服已与买家确认可用该库存"
|
||||
/>
|
||||
</Space>
|
||||
) : null}
|
||||
<Typography.Text type="secondary">
|
||||
已选 {selectedIds.length} 条,合计成本 {money(totalPrice)}
|
||||
</Typography.Text>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ import {
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
Divider,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
@@ -270,6 +271,10 @@ export default function ConsignmentInventoryPanel() {
|
||||
values.salePrice !== undefined
|
||||
? Math.max(0, Math.round(Number(values.salePrice) * 100))
|
||||
: undefined,
|
||||
specValues:
|
||||
values.specValues && Object.keys(values.specValues).length
|
||||
? values.specValues
|
||||
: undefined,
|
||||
buyerName: values.buyerName,
|
||||
shopName: values.shopName,
|
||||
inboundBatch: values.inboundBatch,
|
||||
@@ -349,6 +354,11 @@ export default function ConsignmentInventoryPanel() {
|
||||
render: (_, record) => (
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, fontSize: 14 }}>{record.itemName}</div>
|
||||
{record.specText ? (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{record.specText}
|
||||
</Text>
|
||||
) : null}
|
||||
<Space size={4} align="center">
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
寄卖单号
|
||||
@@ -666,6 +676,10 @@ export default function ConsignmentInventoryPanel() {
|
||||
setEditingItem(record)
|
||||
editForm.setFieldsValue({
|
||||
...record,
|
||||
specValues:
|
||||
record.specValues && Object.keys(record.specValues).length
|
||||
? record.specValues
|
||||
: undefined,
|
||||
costPrice: record.costPriceFen / 100,
|
||||
salePrice: (record.salePriceFen ?? record.costPriceFen) / 100,
|
||||
workerId: record.workerId || undefined,
|
||||
@@ -961,6 +975,46 @@ export default function ConsignmentInventoryPanel() {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{editingItem?.specs?.length ? (
|
||||
<>
|
||||
<Divider plain>动态规格</Divider>
|
||||
<Row gutter={16}>
|
||||
{editingItem.specs.map((field) => (
|
||||
<Col span={8} key={field.key}>
|
||||
<Form.Item name={['specValues', field.key]} label={field.label}>
|
||||
{field.type === 'select' || field.type === 'radio' ? (
|
||||
field.type === 'radio' ? (
|
||||
<Radio.Group
|
||||
options={field.options
|
||||
.filter((option) => option.enabled !== false)
|
||||
.map((option) => ({ label: option.label, value: option.value }))}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
options={field.options
|
||||
.filter((option) => option.enabled !== false)
|
||||
.map((option) => ({ label: option.label, value: option.value }))}
|
||||
/>
|
||||
)
|
||||
) : field.type === 'number' ? (
|
||||
<InputNumber style={{ width: '100%' }} />
|
||||
) : field.type === 'date' ? (
|
||||
<Input type="date" />
|
||||
) : field.type === 'textarea' ? (
|
||||
<Input.TextArea
|
||||
placeholder={field.placeholder || undefined}
|
||||
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||
/>
|
||||
) : (
|
||||
<Input placeholder={field.placeholder || undefined} />
|
||||
)}
|
||||
</Form.Item>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Form.Item name="buyerName" label="收购人">
|
||||
|
||||
@@ -166,6 +166,10 @@ export default function ConsignmentSubmissionsPanel() {
|
||||
series: values.series,
|
||||
diamondLevel: Number(values.diamondLevel || 1),
|
||||
brillianceCount: Number(values.brillianceCount || 0),
|
||||
specValues:
|
||||
values.specValues && Object.keys(values.specValues).length
|
||||
? values.specValues
|
||||
: undefined,
|
||||
sellerContact: values.sellerContact,
|
||||
sellerGameId: values.sellerGameId,
|
||||
sellerPaymentInfo: values.sellerPaymentInfo,
|
||||
@@ -375,6 +379,10 @@ export default function ConsignmentSubmissionsPanel() {
|
||||
setApproveModalItem(record)
|
||||
approveForm.setFieldsValue({
|
||||
...record,
|
||||
specValues:
|
||||
record.specValues && Object.keys(record.specValues).length
|
||||
? record.specValues
|
||||
: undefined,
|
||||
costPrice: record.askingPriceFen / 100,
|
||||
sellerContact: record.contactInfo,
|
||||
buyerName: '客服',
|
||||
@@ -667,6 +675,47 @@ export default function ConsignmentSubmissionsPanel() {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{approveModalItem?.specs?.length ? (
|
||||
<Row gutter={12}>
|
||||
{approveModalItem.specs.map((field) => (
|
||||
<Col span={8} key={field.key}>
|
||||
<Form.Item
|
||||
name={['specValues', field.key]}
|
||||
label={field.label}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
{field.type === 'select' || field.type === 'radio' ? (
|
||||
field.type === 'radio' ? (
|
||||
<Radio.Group
|
||||
options={field.options
|
||||
.filter((option) => option.enabled !== false)
|
||||
.map((option) => ({ label: option.label, value: option.value }))}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
options={field.options
|
||||
.filter((option) => option.enabled !== false)
|
||||
.map((option) => ({ label: option.label, value: option.value }))}
|
||||
/>
|
||||
)
|
||||
) : field.type === 'number' ? (
|
||||
<InputNumber style={{ width: '100%' }} />
|
||||
) : field.type === 'date' ? (
|
||||
<Input type="date" />
|
||||
) : field.type === 'textarea' ? (
|
||||
<Input.TextArea
|
||||
placeholder={field.placeholder || undefined}
|
||||
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||
/>
|
||||
) : (
|
||||
<Input placeholder={field.placeholder || undefined} />
|
||||
)}
|
||||
</Form.Item>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
) : null}
|
||||
|
||||
{/* 分组二:履约与打手 */}
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
InboxOutlined,
|
||||
SearchOutlined,
|
||||
PictureOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
UserOutlined,
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
DatePicker,
|
||||
Divider,
|
||||
Empty,
|
||||
Form,
|
||||
@@ -38,14 +38,14 @@ import {
|
||||
Typography,
|
||||
Spin,
|
||||
} from 'antd'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import ImageUpload from '@/components/files/ImageUpload'
|
||||
import { WorkerContactModal } from '@/components/worker/WorkerContactModal'
|
||||
import {
|
||||
confirmWorkerConsignmentFriendAdded,
|
||||
fetchWorkerConsignmentContacts,
|
||||
fetchWorkerConsignmentPriceSuggestion,
|
||||
fetchWorkerConsignmentPricePreview,
|
||||
fetchWorkerConsignmentProfiles,
|
||||
fetchWorkerConsignments,
|
||||
fetchWorkerProfile,
|
||||
@@ -54,35 +54,19 @@ import {
|
||||
updateWorkerConsignmentPrice,
|
||||
} from '@/services/worker'
|
||||
import type { WorkerConsignmentRecord } from '@/services/worker'
|
||||
import type { WorkerConsignmentProfile } from '@/types/consignment'
|
||||
import type {
|
||||
ConsignmentSpecField,
|
||||
ConsignmentSpecValues,
|
||||
WorkerConsignmentProfile,
|
||||
} from '@/types/consignment'
|
||||
import type { UploadedFile } from '@/types/worker-platform'
|
||||
import {
|
||||
SURNAN_DEFAULT_ZONE,
|
||||
SURNAN_PUBLISH_SYSTEM_OPTIONS,
|
||||
SURNAN_PUBLISH_ZONE_OPTIONS,
|
||||
} from '@/utils/consignment-spec'
|
||||
import { buildSpecItemName } from '@/utils/consignment-spec'
|
||||
import { formatConsignmentContactSupporter } from '@/utils/consignment-contact'
|
||||
import { formatGiftCooldownRemaining } from '@/utils/consignment-cooldown'
|
||||
import { canWorkerPublishConsignment } from '@/utils/consignment-whitelist'
|
||||
import { formatDateTime } from '@/utils/date-time'
|
||||
import { useIsMobile } from '@/utils/use-is-mobile'
|
||||
|
||||
const SERIES_OPTIONS = [
|
||||
{ label: '暗潮', value: '暗潮' },
|
||||
{ label: '怒嚎', value: '怒嚎' },
|
||||
{ label: '血宴', value: '血宴' },
|
||||
]
|
||||
|
||||
const DIAMOND_OPTIONS = [1, 2, 3, 4, 5, 6, 7].map((v) => ({
|
||||
label: `${v} 钻`,
|
||||
value: v,
|
||||
}))
|
||||
|
||||
const BRILLIANCE_OPTIONS = [
|
||||
{ label: '无璀璨 (0)', value: 0 },
|
||||
...[1, 2, 3, 4, 5, 6, 7].map((v) => ({ label: `${v} 璀璨`, value: v })),
|
||||
]
|
||||
|
||||
type RecordFilter = 'all' | 'reviewing' | 'active' | 'finished'
|
||||
|
||||
const DEPOSIT_TIP_KEY = 'worker-consignment-deposit-tip-seen'
|
||||
@@ -176,11 +160,11 @@ export default function WorkerConsignmentPage() {
|
||||
const [cooldownNow, setCooldownNow] = useState(() => Date.now())
|
||||
const [form] = Form.useForm()
|
||||
const currentDiamondLevel = Form.useWatch('diamondLevel', form)
|
||||
// 寄售价:完全由后台「接单模板」决定(规格选齐后自动带出),卖家只读不可改。
|
||||
// 寄售价:按后台配置(模板折扣价 / 固定价)自动带出,打手只能往下调。
|
||||
const [priceState, setPriceState] = useState<
|
||||
| { status: 'idle' }
|
||||
| { status: 'loading' }
|
||||
| { status: 'matched'; fen: number }
|
||||
| { status: 'matched'; fen: number; capFen: number }
|
||||
| { status: 'empty' }
|
||||
>({ status: 'idle' })
|
||||
const priceFetchTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
@@ -218,6 +202,7 @@ export default function WorkerConsignmentPage() {
|
||||
consignmentProfiles.find((profile) => profile.profileId === selectedProfileId) ||
|
||||
consignmentProfiles[0] ||
|
||||
null
|
||||
const selectedSpecs: ConsignmentSpecField[] = selectedProfile?.config.specs ?? []
|
||||
useEffect(() => {
|
||||
if (selectedProfileId === null && consignmentProfiles.length > 0) {
|
||||
setSelectedProfileId(consignmentProfiles[0]!.profileId)
|
||||
@@ -262,9 +247,7 @@ export default function WorkerConsignmentPage() {
|
||||
2000,
|
||||
)
|
||||
const publishDepositYuan = publishDepositFen / 100
|
||||
const manualPriceAllowed =
|
||||
selectedProfile?.config.pricing.allowWorkerPrice === true ||
|
||||
(Boolean(selectedProfile) && !selectedProfile!.config.pricing.useTemplatePrice)
|
||||
const manualPriceAllowed = selectedProfile?.config.pricing.allowWorkerPrice !== false
|
||||
const availableAmount = Number(worker?.wallet?.availableAmount || 0)
|
||||
const canPublish = availableAmount >= publishDepositFen
|
||||
const canConsign = canWorkerPublishConsignment({
|
||||
@@ -338,6 +321,24 @@ export default function WorkerConsignmentPage() {
|
||||
})
|
||||
}, [profileQuery.isLoading, modal, publishDepositYuan])
|
||||
|
||||
function collectSpecValues(): ConsignmentSpecValues {
|
||||
const values: ConsignmentSpecValues = {}
|
||||
for (const field of selectedSpecs) {
|
||||
const raw = form.getFieldValue(field.key)
|
||||
if (raw === undefined || raw === null || String(raw).trim() === '') continue
|
||||
if (
|
||||
field.type === 'date' &&
|
||||
typeof raw === 'object' &&
|
||||
typeof (raw as { format?: unknown }).format === 'function'
|
||||
) {
|
||||
values[field.key] = (raw as { format: (pattern: string) => string }).format('YYYY-MM-DD')
|
||||
continue
|
||||
}
|
||||
values[field.key] = typeof raw === 'number' ? raw : String(raw)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
function handleFormValuesChange(changed: Record<string, unknown>) {
|
||||
// 钻级改变时:璀璨数不能超过钻石等级(如 5钻调成 2钻,原 5璀自动收敛到 2璀)
|
||||
if (changed && changed.diamondLevel !== undefined) {
|
||||
@@ -347,28 +348,22 @@ export default function WorkerConsignmentPage() {
|
||||
form.setFieldsValue({ brillianceCount: newDia })
|
||||
}
|
||||
}
|
||||
const sys = form.getFieldValue('system')
|
||||
const zone = form.getFieldValue('serverZone')
|
||||
const ser = form.getFieldValue('series')
|
||||
const dia = form.getFieldValue('diamondLevel')
|
||||
const bri = form.getFieldValue('brillianceCount')
|
||||
if (sys && zone && ser && dia != null && bri != null) {
|
||||
const briNum = Number(bri)
|
||||
const briStr = briNum > 0 ? `${briNum}璀璨` : ''
|
||||
// 区服是通配值(互通/通用)时不拼进物品名:对买家无信息量,"安卓互通暗潮…"读着也别扭
|
||||
const zoneLabel = zone === '互通' || zone === '通用' ? '' : zone
|
||||
form.setFieldsValue({
|
||||
itemName: `${sys}${zoneLabel}${ser}${dia}钻${briStr} 苏尔南`,
|
||||
})
|
||||
}
|
||||
|
||||
// 规格选齐(系统+系列+钻级+璀璨)后从「接单模板」带出寄售价:安卓+暗潮+1钻+0璀 → 模板价 ¥120。
|
||||
// 价格只读、卖家不用填;只有规格真的变了才重新匹配(改联系方式等字段不会触发)。
|
||||
const specKey =
|
||||
sys && ser && dia != null && Number(dia) > 0 && bri != null
|
||||
? `${sys}|${ser}|${dia}|${bri}`
|
||||
: ''
|
||||
if (!specKey) {
|
||||
const specValues = collectSpecValues()
|
||||
const itemName = buildSpecItemName(
|
||||
selectedProfile?.config.nameTemplate || '',
|
||||
selectedSpecs,
|
||||
specValues,
|
||||
selectedProfile?.categoryName || '',
|
||||
)
|
||||
if (itemName) form.setFieldsValue({ itemName })
|
||||
|
||||
const missing = selectedSpecs.some(
|
||||
(field) =>
|
||||
field.required &&
|
||||
(specValues[field.key] === undefined || String(specValues[field.key]).trim() === ''),
|
||||
)
|
||||
if (missing) {
|
||||
if (lastPriceQueryKeyRef.current !== '') {
|
||||
lastPriceQueryKeyRef.current = ''
|
||||
setPriceState({ status: 'idle' })
|
||||
@@ -376,6 +371,8 @@ export default function WorkerConsignmentPage() {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const specKey = JSON.stringify(specValues)
|
||||
if (specKey === lastPriceQueryKeyRef.current) return
|
||||
lastPriceQueryKeyRef.current = specKey
|
||||
setPriceState({ status: 'loading' })
|
||||
@@ -385,17 +382,18 @@ export default function WorkerConsignmentPage() {
|
||||
priceFetchTimer.current = setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetchWorkerConsignmentPriceSuggestion({
|
||||
system: String(sys),
|
||||
series: String(ser),
|
||||
diamondLevel: Number(dia),
|
||||
brillianceCount: Number(bri),
|
||||
const res = await fetchWorkerConsignmentPricePreview({
|
||||
profileId: selectedProfile?.profileId,
|
||||
specValues,
|
||||
})
|
||||
if (lastPriceQueryKeyRef.current !== specKey) return
|
||||
const fen = Number(res.data?.suggestedPriceFen || 0)
|
||||
const capFen = Number(res.data?.capFen || 0)
|
||||
if (fen > 0) {
|
||||
setPriceState({ status: 'matched', fen })
|
||||
form.setFieldsValue({ askingPrice: fen / 100 })
|
||||
setPriceState({ status: 'matched', fen, capFen: capFen > 0 ? capFen : fen })
|
||||
if (manualPriceAllowed) {
|
||||
form.setFieldsValue({ askingPrice: fen / 100 })
|
||||
}
|
||||
} else {
|
||||
setPriceState({ status: 'empty' })
|
||||
}
|
||||
@@ -434,22 +432,34 @@ export default function WorkerConsignmentPage() {
|
||||
return
|
||||
}
|
||||
|
||||
// 寄售价:配置允许改价/不使用模板价时按打手填写;否则必须匹配到接单模板价。
|
||||
// 寄售价:按后台配置带出系统建议价;允许改价时可下调,但不能高于建议价。
|
||||
const minPriceFen = Math.max(0, selectedProfile?.config.pricing.minPriceFen || 0)
|
||||
let askingPriceFen = priceState.status === 'matched' ? priceState.fen : 0
|
||||
const suggestedPriceFen = priceState.status === 'matched' ? priceState.fen : 0
|
||||
const capPriceFen = priceState.status === 'matched' ? priceState.capFen : 0
|
||||
let askingPriceFen = suggestedPriceFen
|
||||
if (manualPriceAllowed) {
|
||||
askingPriceFen = Math.max(0, Math.round(Number(values.askingPrice || 0) * 100))
|
||||
if (askingPriceFen < Math.max(100, minPriceFen)) {
|
||||
message.error(`寄售价格不能少于 ¥${(Math.max(100, minPriceFen) / 100).toFixed(2)}`)
|
||||
const submittedPriceFen = Math.max(0, Math.round(Number(values.askingPrice || 0) * 100))
|
||||
if (submittedPriceFen > 0) {
|
||||
if (capPriceFen > 0 && submittedPriceFen > capPriceFen) {
|
||||
message.error(`寄售价格不能高于系统建议价 ¥${(capPriceFen / 100).toFixed(2)}`)
|
||||
return
|
||||
}
|
||||
if (submittedPriceFen < Math.max(100, minPriceFen)) {
|
||||
message.error(`寄售价格不能少于 ¥${(Math.max(100, minPriceFen) / 100).toFixed(2)}`)
|
||||
return
|
||||
}
|
||||
askingPriceFen = submittedPriceFen
|
||||
} else if (suggestedPriceFen <= 0) {
|
||||
message.error('该规格暂无价格,请填写寄售价格')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if (priceState.status === 'loading') {
|
||||
message.warning('正在匹配接单模板价,请稍候')
|
||||
message.warning('正在计算系统建议价,请稍候')
|
||||
return
|
||||
}
|
||||
if (priceState.status !== 'matched') {
|
||||
message.error('该规格暂无接单模板价,请联系客服补充模板后再提报')
|
||||
message.error('该规格暂无价格,请联系客服补充配置后再提报')
|
||||
return
|
||||
}
|
||||
if (askingPriceFen < 100) {
|
||||
@@ -458,11 +468,15 @@ export default function WorkerConsignmentPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const briNum = Number(values.brillianceCount || 0)
|
||||
const briStr = briNum > 0 ? `${briNum}璀璨` : ''
|
||||
const specValues = collectSpecValues()
|
||||
const itemName =
|
||||
String(values.itemName || '').trim() ||
|
||||
`${values.system}${values.serverZone}${values.series}${values.diamondLevel}钻${briStr} 苏尔南`
|
||||
buildSpecItemName(
|
||||
selectedProfile?.config.nameTemplate || '',
|
||||
selectedSpecs,
|
||||
specValues,
|
||||
selectedProfile?.categoryName || '',
|
||||
)
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
@@ -470,11 +484,12 @@ export default function WorkerConsignmentPage() {
|
||||
profileId: selectedProfile?.profileId,
|
||||
quantity: Math.max(1, Number(values.quantity || 1)),
|
||||
itemName,
|
||||
system: values.system,
|
||||
serverZone: values.serverZone,
|
||||
series: values.series,
|
||||
diamondLevel: Number(values.diamondLevel || 1),
|
||||
brillianceCount: Number(values.brillianceCount || 0),
|
||||
specValues,
|
||||
system: String(specValues.system || ''),
|
||||
serverZone: String(specValues.serverZone || ''),
|
||||
series: String(specValues.series || ''),
|
||||
diamondLevel: Number(specValues.diamondLevel || 1),
|
||||
brillianceCount: Number(specValues.brillianceCount || 0),
|
||||
askingPriceFen,
|
||||
sellerContact: values.sellerContact,
|
||||
sellerPhone: values.sellerPhone || worker?.phone || '',
|
||||
@@ -653,94 +668,105 @@ export default function WorkerConsignmentPage() {
|
||||
|
||||
<div className="worker-consignment-specs">
|
||||
<div className="worker-consignment-spec-grid">
|
||||
<Form.Item
|
||||
name="system"
|
||||
label="操作系统"
|
||||
rules={[{ required: true, message: '请选择系统' }]}
|
||||
className="worker-consignment-spec-card"
|
||||
>
|
||||
<Radio.Group
|
||||
className="worker-consignment-chips"
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
options={[...SURNAN_PUBLISH_SYSTEM_OPTIONS]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="serverZone"
|
||||
label="游戏区服"
|
||||
rules={[{ required: true, message: '请选择区服' }]}
|
||||
// 苏尔南区服固定「通用」(QQ/微信互通),唯一选项默认选中
|
||||
initialValue={SURNAN_DEFAULT_ZONE}
|
||||
className="worker-consignment-spec-card"
|
||||
>
|
||||
<Radio.Group
|
||||
className="worker-consignment-chips"
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
options={[...SURNAN_PUBLISH_ZONE_OPTIONS]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="series"
|
||||
label="系列 / 枪系"
|
||||
rules={[{ required: true, message: '请选择枪系' }]}
|
||||
className="worker-consignment-spec-card"
|
||||
>
|
||||
<Radio.Group
|
||||
className="worker-consignment-chips"
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
options={SERIES_OPTIONS}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="diamondLevel"
|
||||
label="钻石等级"
|
||||
rules={[{ required: true, message: '请选择钻级' }]}
|
||||
className="worker-consignment-spec-card is-count"
|
||||
>
|
||||
<Radio.Group
|
||||
className="worker-consignment-chips is-count"
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
options={DIAMOND_OPTIONS.map((o) => ({
|
||||
label: `${o.value}钻`,
|
||||
value: o.value,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="brillianceCount"
|
||||
label="璀璨数"
|
||||
rules={[
|
||||
{ required: true, message: '请选择璀璨数' },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
const dia = getFieldValue('diamondLevel')
|
||||
if (value != null && dia != null && Number(value) > Number(dia)) {
|
||||
return Promise.reject(
|
||||
new Error(`璀璨数不能超过钻石等级(当前${dia}钻最多选${dia}璀)`),
|
||||
)
|
||||
}
|
||||
return Promise.resolve()
|
||||
},
|
||||
}),
|
||||
]}
|
||||
initialValue={0}
|
||||
className="worker-consignment-spec-card is-count"
|
||||
>
|
||||
<Radio.Group
|
||||
className="worker-consignment-chips is-count"
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
options={BRILLIANCE_OPTIONS.map((o) => ({
|
||||
label: o.value === 0 ? '无' : `${o.value}璀`,
|
||||
value: o.value,
|
||||
disabled: currentDiamondLevel != null && o.value > Number(currentDiamondLevel),
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
{selectedSpecs.map((field) => {
|
||||
const isCountField = field.key === 'diamondLevel' || field.key === 'brillianceCount'
|
||||
const options = field.options
|
||||
.filter((option) => option.enabled !== false)
|
||||
.map((option) => ({
|
||||
label: option.label,
|
||||
value: option.value,
|
||||
disabled:
|
||||
field.key === 'brillianceCount' &&
|
||||
currentDiamondLevel != null &&
|
||||
Number(option.value) > Number(currentDiamondLevel),
|
||||
}))
|
||||
const defaultValue =
|
||||
field.key === 'serverZone'
|
||||
? options.some((option) => option.value === '互通')
|
||||
? '互通'
|
||||
: options.length === 1
|
||||
? options[0]?.value
|
||||
: undefined
|
||||
: field.key === 'brillianceCount'
|
||||
? 0
|
||||
: undefined
|
||||
return (
|
||||
<Form.Item
|
||||
key={field.key}
|
||||
name={field.key}
|
||||
label={field.label}
|
||||
initialValue={defaultValue}
|
||||
extra={field.helpText || undefined}
|
||||
className={`worker-consignment-spec-card${isCountField ? ' is-count' : ''}`}
|
||||
rules={[
|
||||
...(field.required
|
||||
? [{ required: true, message: `请选择或填写${field.label}` }]
|
||||
: []),
|
||||
...(field.key === 'brillianceCount'
|
||||
? [
|
||||
({ getFieldValue }: { getFieldValue: (name: string) => unknown }) => ({
|
||||
validator(_: unknown, value: unknown) {
|
||||
const diamond = getFieldValue('diamondLevel')
|
||||
if (
|
||||
value != null &&
|
||||
diamond != null &&
|
||||
Number(value) > Number(diamond)
|
||||
) {
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
`璀璨数不能超过钻石等级(当前${diamond}钻最多选${diamond}璀)`,
|
||||
),
|
||||
)
|
||||
}
|
||||
return Promise.resolve()
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
>
|
||||
{field.type === 'select' ? (
|
||||
<Select
|
||||
className="worker-consignment-select"
|
||||
options={options}
|
||||
placeholder={field.placeholder || `请选择${field.label}`}
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
) : field.type === 'radio' ? (
|
||||
<Radio.Group
|
||||
className={`worker-consignment-chips${isCountField ? ' is-count' : ''}`}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
options={options}
|
||||
/>
|
||||
) : field.type === 'number' ? (
|
||||
<InputNumber
|
||||
min={0}
|
||||
precision={0}
|
||||
placeholder={field.placeholder || undefined}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
) : field.type === 'textarea' ? (
|
||||
<Input.TextArea
|
||||
placeholder={field.placeholder || undefined}
|
||||
maxLength={2000}
|
||||
autoSize={{ minRows: 2, maxRows: 5 }}
|
||||
allowClear
|
||||
/>
|
||||
) : field.type === 'date' ? (
|
||||
<DatePicker
|
||||
format="YYYY-MM-DD"
|
||||
placeholder={field.placeholder || '请选择日期'}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
) : (
|
||||
<Input placeholder={field.placeholder || undefined} maxLength={200} allowClear />
|
||||
)}
|
||||
</Form.Item>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="worker-consignment-price-grid">
|
||||
@@ -748,8 +774,8 @@ export default function WorkerConsignmentPage() {
|
||||
label="寄售价格"
|
||||
tooltip={
|
||||
manualPriceAllowed
|
||||
? '当前寄卖配置允许自定义售价,价格不得低于配置的最低价。'
|
||||
: '由后台「接单模板」按系统/系列/钻级/璀璨自动匹配,卖家无需填写;需要调整请联系客服。'
|
||||
? `默认按后台配置带出(模板折扣价 / 固定价),可下调,不能高于系统建议价${selectedProfile?.config.pricing.minPriceFen ? `;最低 ¥${(selectedProfile.config.pricing.minPriceFen / 100).toFixed(2)}` : ''}。`
|
||||
: '由后台寄卖配置自动带出,卖家不可修改;需要调整请联系客服。'
|
||||
}
|
||||
className="worker-consignment-spec-card is-price"
|
||||
>
|
||||
@@ -757,8 +783,14 @@ export default function WorkerConsignmentPage() {
|
||||
<Form.Item name="askingPrice" noStyle>
|
||||
<InputNumber
|
||||
min={Math.max(1, Number(selectedProfile?.config.pricing.minPriceFen || 0) / 100)}
|
||||
max={
|
||||
priceState.status === 'matched' && priceState.capFen > 0
|
||||
? priceState.capFen / 100
|
||||
: undefined
|
||||
}
|
||||
precision={2}
|
||||
addonBefore="¥"
|
||||
placeholder="选完规格后自动带出"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -766,12 +798,12 @@ export default function WorkerConsignmentPage() {
|
||||
<div className="worker-consignment-price-readonly">
|
||||
{priceState.status === 'loading' ? (
|
||||
<span className="is-pending">
|
||||
<Spin size="small" /> 匹配模板价…
|
||||
<Spin size="small" /> 计算建议价…
|
||||
</span>
|
||||
) : priceState.status === 'matched' ? (
|
||||
<span className="is-amount">¥{(priceState.fen / 100).toFixed(0)}</span>
|
||||
) : priceState.status === 'empty' ? (
|
||||
<span className="is-empty">该规格暂无模板价,请联系客服</span>
|
||||
<span className="is-empty">该规格暂无价格,请联系客服</span>
|
||||
) : (
|
||||
<span className="is-empty">选完规格后自动带出</span>
|
||||
)}
|
||||
@@ -1032,6 +1064,17 @@ export default function WorkerConsignmentPage() {
|
||||
}
|
||||
const profileId = Number(String(key).replace('publish-', ''))
|
||||
if (Number.isInteger(profileId) && profileId > 0) {
|
||||
if (profileId !== selectedProfileId) {
|
||||
const nextProfile = consignmentProfiles.find((item) => item.profileId === profileId)
|
||||
const nextSpecs = nextProfile?.config.specs ?? []
|
||||
const cleared: Record<string, undefined> = {}
|
||||
for (const field of nextSpecs) cleared[field.key] = undefined
|
||||
cleared.askingPrice = undefined
|
||||
cleared.itemName = undefined
|
||||
form.setFieldsValue(cleared)
|
||||
setPriceState({ status: 'idle' })
|
||||
lastPriceQueryKeyRef.current = ''
|
||||
}
|
||||
setSelectedProfileId(profileId)
|
||||
}
|
||||
setActiveTab('publish')
|
||||
@@ -1474,13 +1517,19 @@ function ConsignmentRecordCard({
|
||||
</Typography.Text>
|
||||
<Tag color={record.color}>{record.label}</Tag>
|
||||
</div>
|
||||
<SpecTags
|
||||
system={record.system}
|
||||
serverZone={record.serverZone}
|
||||
series={record.series}
|
||||
diamondLevel={record.diamondLevel}
|
||||
brillianceCount={record.brillianceCount}
|
||||
/>
|
||||
{record.specText ? (
|
||||
<Typography.Text type="secondary" className="worker-consignment-record-spec-text">
|
||||
{record.specText}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<SpecTags
|
||||
system={record.system}
|
||||
serverZone={record.serverZone}
|
||||
series={record.series}
|
||||
diamondLevel={record.diamondLevel}
|
||||
brillianceCount={record.brillianceCount}
|
||||
/>
|
||||
)}
|
||||
<div className="worker-consignment-record-meta">
|
||||
<span>
|
||||
寄售价{' '}
|
||||
@@ -1695,12 +1744,18 @@ function ConsignmentRecordCard({
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={record.priceCapFen && record.priceCapFen > 0 ? record.priceCapFen / 100 : undefined}
|
||||
precision={2}
|
||||
addonBefore="¥"
|
||||
value={priceYuan}
|
||||
onChange={(value) => setPriceYuan(Number(value || 0))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
{record.priceCapFen && record.priceCapFen > 0 ? (
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 8 }}>
|
||||
最高不能超过系统建议价 ¥{(record.priceCapFen / 100).toFixed(2)}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</Modal>
|
||||
</article>
|
||||
)
|
||||
|
||||
@@ -54,6 +54,8 @@ export function fetchAdminConsignmentMatchCandidates(workOrderId: number | strin
|
||||
export function matchAdminConsignmentWorkOrder(payload: {
|
||||
workOrderId: number
|
||||
inventoryIds: number[]
|
||||
allowNonExact?: boolean
|
||||
reason?: string
|
||||
}) {
|
||||
return apiPost<{ matched: boolean; matchedCount: number }>(
|
||||
'/api/v1/admin/worker-platform/consignment/match',
|
||||
|
||||
@@ -31,6 +31,7 @@ import type {
|
||||
ConsignmentBuyerInfo,
|
||||
ConsignmentContactSupporter,
|
||||
ConsignmentSellerInfo,
|
||||
ConsignmentSpecValues,
|
||||
WorkerConsignmentProfile,
|
||||
} from '@/types/consignment'
|
||||
|
||||
@@ -378,11 +379,13 @@ export type WorkerConsignmentPublishPayload = {
|
||||
profileId?: number
|
||||
quantity?: number
|
||||
itemName?: string
|
||||
system: string
|
||||
serverZone: string
|
||||
series: string
|
||||
diamondLevel: number
|
||||
brillianceCount: number
|
||||
/** 动态规格值(新链路);内置 5 字段仍可平铺传,旧调用兼容 */
|
||||
specValues?: ConsignmentSpecValues
|
||||
system?: string
|
||||
serverZone?: string
|
||||
series?: string
|
||||
diamondLevel?: number
|
||||
brillianceCount?: number
|
||||
askingPriceFen: number
|
||||
sellerContact: string
|
||||
sellerPhone?: string
|
||||
@@ -453,6 +456,11 @@ export type WorkerConsignmentRecord = {
|
||||
brillianceCount: number
|
||||
askingPriceFen: number
|
||||
salePriceFen?: number
|
||||
/** 打手改价上限(分);0 = 不限制 */
|
||||
priceCapFen?: number
|
||||
/** 动态规格值与展示文本 */
|
||||
specValues?: ConsignmentSpecValues
|
||||
specText?: string
|
||||
quantity?: number
|
||||
remainingQuantity?: number
|
||||
listedAt?: string | null
|
||||
@@ -574,6 +582,7 @@ export function submitWorkerConsignmentGiftEvidence(
|
||||
|
||||
/** 发布寄卖:按规格(系统/系列/钻级/璀璨)从「接单模板」带出建议价,如 安卓+暗潮+1钻+0璀 → ¥120 */
|
||||
export function fetchWorkerConsignmentPriceSuggestion(params: {
|
||||
profileId?: number
|
||||
system: string
|
||||
series: string
|
||||
diamondLevel: number
|
||||
@@ -581,11 +590,29 @@ export function fetchWorkerConsignmentPriceSuggestion(params: {
|
||||
}) {
|
||||
return apiGet<{
|
||||
suggestedPriceFen: number | null
|
||||
capFen: number
|
||||
templatePriceFen: number
|
||||
source: 'template' | 'fixed' | 'none'
|
||||
matchedCount: number
|
||||
matchedRules: { ruleKey: string; productName: string; rewardAmountFen: number }[]
|
||||
}>('/api/v1/worker/consignment/price-suggestion', params)
|
||||
}
|
||||
|
||||
/** 动态规格发布:按 profileId + specValues 算价(模板折扣价 / 固定价)。 */
|
||||
export function fetchWorkerConsignmentPricePreview(payload: {
|
||||
profileId?: number
|
||||
specValues: ConsignmentSpecValues
|
||||
}) {
|
||||
return apiPost<{
|
||||
suggestedPriceFen: number | null
|
||||
capFen: number
|
||||
templatePriceFen: number
|
||||
source: 'template' | 'fixed' | 'none'
|
||||
matchedCount: number
|
||||
matchedRules: { ruleKey: string; productName: string; rewardAmountFen: number }[]
|
||||
}>('/api/v1/worker/consignment/price-preview', payload)
|
||||
}
|
||||
|
||||
export type OrderServiceField = {
|
||||
key: string
|
||||
label: string
|
||||
|
||||
@@ -28,6 +28,31 @@ export type ConsignmentContactFields = {
|
||||
contactSupporterName?: string
|
||||
}
|
||||
|
||||
/** 动态规格值:key -> 选项值/数字/文本 */
|
||||
export type ConsignmentSpecValues = Record<string, string | number>
|
||||
|
||||
export type ConsignmentSpecOption = {
|
||||
label: string
|
||||
value: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type ConsignmentSpecFieldType = 'text' | 'textarea' | 'select' | 'radio' | 'number' | 'date'
|
||||
|
||||
export type ConsignmentSpecField = {
|
||||
key: string
|
||||
label: string
|
||||
type: ConsignmentSpecFieldType
|
||||
required: boolean
|
||||
options: ConsignmentSpecOption[]
|
||||
placeholder: string
|
||||
helpText: string
|
||||
matchMode: 'exact' | 'minimum' | 'wildcard' | 'ignore'
|
||||
wildcardValues: string[]
|
||||
showInItemName: boolean
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 客服列表 / 详情接口额外返回的寄卖凭证字段(提报列表与库存列表都有)。
|
||||
*
|
||||
@@ -98,6 +123,13 @@ export type ConsignmentInventory = ConsignmentEvidenceFields &
|
||||
salePriceFen?: number
|
||||
listedAt?: string | null
|
||||
priceUpdatedAt?: string | null
|
||||
/** 动态规格值与展示文本 */
|
||||
specValues?: ConsignmentSpecValues
|
||||
specText?: string
|
||||
/** 该库存快照对应的规格定义(旧库存为空,前端回退固定 5 项) */
|
||||
specs?: ConsignmentSpecField[]
|
||||
/** 打手改价上限(分);0 = 不限制 */
|
||||
priceCapFen?: number
|
||||
canEditPrice?: boolean
|
||||
priceDropSuggestion?: {
|
||||
suggestedPriceFen: number
|
||||
@@ -180,6 +212,11 @@ export type ConsignmentSubmission = ConsignmentEvidenceFields &
|
||||
/** 分类级寄卖配置与数量/售价 */
|
||||
profileId?: number | null
|
||||
profileVersionId?: number | null
|
||||
/** 动态规格值与展示文本 */
|
||||
specValues?: ConsignmentSpecValues
|
||||
specText?: string
|
||||
specs?: ConsignmentSpecField[]
|
||||
priceCapFen?: number
|
||||
quantity?: number
|
||||
requestedSalePriceFen?: number
|
||||
workerId?: number | null
|
||||
@@ -307,7 +344,20 @@ export type ConsignmentProfileConfig = {
|
||||
brillianceCount: boolean
|
||||
quantity: boolean
|
||||
}
|
||||
pricing: { useTemplatePrice: boolean; allowWorkerPrice: boolean; minPriceFen: number }
|
||||
/** 动态规格字段(完全由运营配置,默认空) */
|
||||
specs: ConsignmentSpecField[]
|
||||
/** 物品名模板,支持 {system}/{series}/{category} 等占位符 */
|
||||
nameTemplate: string
|
||||
pricing: {
|
||||
/** 接单模板价打折 / 固定价格 */
|
||||
priceType: 'template_discount' | 'fixed'
|
||||
/** 几折,0.1 - 10,例如 9.5 */
|
||||
discount: number
|
||||
fixedPriceFen: number
|
||||
allowWorkerPrice: boolean
|
||||
minPriceFen: number
|
||||
missingPricePolicy: 'block' | 'manual'
|
||||
}
|
||||
deposit: {
|
||||
enabled: boolean
|
||||
mode: 'fixed' | 'per_quantity'
|
||||
@@ -332,6 +382,8 @@ export type ConsignmentProfileConfig = {
|
||||
}
|
||||
}
|
||||
|
||||
export type ConsignmentPricingConfig = ConsignmentProfileConfig['pricing']
|
||||
|
||||
export type ConsignmentProfile = {
|
||||
profileId: number
|
||||
categoryId: number
|
||||
@@ -381,6 +433,17 @@ export type ConsignmentMatchCandidate = {
|
||||
series: string
|
||||
diamondLevel: number
|
||||
brillianceCount: number
|
||||
specValues?: ConsignmentSpecValues
|
||||
specText?: string
|
||||
matchType?: 'exact' | 'partial' | 'conflict' | 'unknown'
|
||||
matchReasons?: string[]
|
||||
matchDetails?: Array<{
|
||||
key: string
|
||||
label: string
|
||||
orderValue: string
|
||||
inventoryValue: string
|
||||
status: 'match' | 'partial' | 'conflict' | 'unknown' | 'ignored'
|
||||
}>
|
||||
quantity: number
|
||||
remainingQuantity: number
|
||||
salePriceFen: number
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import type {
|
||||
ConsignmentPricingConfig,
|
||||
ConsignmentSpecField,
|
||||
ConsignmentSpecFieldType,
|
||||
ConsignmentSpecValues,
|
||||
} from '@/types/consignment'
|
||||
|
||||
/**
|
||||
* 寄卖规格选项(单一来源)。
|
||||
*
|
||||
* 两套口径:
|
||||
* 1. 后台全量(CONSIGNMENT_SYSTEM_OPTIONS / CONSIGNMENT_ZONE_OPTIONS):给客服端
|
||||
* 提报审核、库存编辑用 —— 系统 苹果/安卓/通用,区服 微信区/QQ区/互通。
|
||||
* 2. 发布端按物品类型展示(SURNAN_*):打手端「我要寄卖」按物品类型给选项 ——
|
||||
* 苏尔南必须区分 苹果/安卓(不给通用),区服固定「互通」(QQ/微信互通,默认选中)。
|
||||
*
|
||||
* 「互通」在匹配引擎里是区服通配值:互通现货 = 微信/QQ 订单都匹配,反之亦然;
|
||||
* 订单文案里的「通用/全区/不限」会归一化成通配(见后端 ZONE_WILDCARDS)。
|
||||
* 系统侧的「通用」同理是系统通配(订单文案里的「双端/全端」也归一化过来)。
|
||||
* 后续其他品类规则不同(有的不分端),在这里按品类加条目即可。
|
||||
* 旧版客服手工入库表单的选项常量。
|
||||
* 新链路以「每个寄卖配置的 specs」为准,这里只给没有配置快照的旧数据兜底。
|
||||
*/
|
||||
export const CONSIGNMENT_SYSTEM_OPTIONS = [
|
||||
{ label: '苹果', value: '苹果' },
|
||||
@@ -24,13 +21,124 @@ export const CONSIGNMENT_ZONE_OPTIONS = [
|
||||
{ label: '互通(QQ/微信)', value: '互通' },
|
||||
] as const
|
||||
|
||||
/** 打手端发布 · 苏尔南:系统必须二选一,区服固定「互通」 */
|
||||
export const SURNAN_PUBLISH_SYSTEM_OPTIONS = [
|
||||
{ label: '苹果', value: '苹果' },
|
||||
{ label: '安卓', value: '安卓' },
|
||||
] as const
|
||||
export const SPEC_FIELD_TYPE_OPTIONS: Array<{ label: string; value: ConsignmentSpecFieldType }> = [
|
||||
{ label: '文本', value: 'text' },
|
||||
{ label: '长文本', value: 'textarea' },
|
||||
{ label: '下拉', value: 'select' },
|
||||
{ label: '单选', value: 'radio' },
|
||||
{ label: '数字', value: 'number' },
|
||||
{ label: '日期', value: 'date' },
|
||||
]
|
||||
|
||||
export const SURNAN_PUBLISH_ZONE_OPTIONS = [{ label: '互通(QQ/微信)', value: '互通' }] as const
|
||||
export function specFieldTypeLabel(type: ConsignmentSpecFieldType): string {
|
||||
return SPEC_FIELD_TYPE_OPTIONS.find((item) => item.value === type)?.label || '文本'
|
||||
}
|
||||
|
||||
/** 苏尔南发布时区服的默认值(唯一选项,自动选中) */
|
||||
export const SURNAN_DEFAULT_ZONE = '互通'
|
||||
export function isOptionSpecField(field: Pick<ConsignmentSpecField, 'type'>): boolean {
|
||||
return field.type === 'select' || field.type === 'radio'
|
||||
}
|
||||
|
||||
export function formatSpecValue(
|
||||
field: ConsignmentSpecField,
|
||||
value: unknown,
|
||||
options: { forItemName?: boolean } = {},
|
||||
): string {
|
||||
const raw = String(value ?? '').trim()
|
||||
if (!raw) return ''
|
||||
if (options.forItemName && (field.key === 'diamondLevel' || field.key === 'brillianceCount')) {
|
||||
const numeric = Number(raw)
|
||||
if (!Number.isFinite(numeric)) return raw
|
||||
if (field.key === 'brillianceCount' && numeric <= 0) return ''
|
||||
return String(Math.round(numeric))
|
||||
}
|
||||
if (isOptionSpecField(field)) {
|
||||
const option = field.options.find((item) => item.value === raw)
|
||||
if (options.forItemName && field.key === 'serverZone' && field.wildcardValues.includes(raw)) {
|
||||
return ''
|
||||
}
|
||||
return option?.label || raw
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
export function buildSpecText(
|
||||
specs: ConsignmentSpecField[],
|
||||
values: ConsignmentSpecValues | undefined,
|
||||
): string {
|
||||
if (!values) return ''
|
||||
return specs
|
||||
.slice()
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder || a.key.localeCompare(b.key))
|
||||
.map((field) => {
|
||||
const display = formatSpecValue(field, values[field.key])
|
||||
return display ? `${field.label} ${display}` : ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(' / ')
|
||||
}
|
||||
|
||||
export function buildSpecItemName(
|
||||
template: string,
|
||||
specs: ConsignmentSpecField[],
|
||||
values: ConsignmentSpecValues | undefined,
|
||||
categoryName = '',
|
||||
): string {
|
||||
if (!values) return categoryName
|
||||
const tokens = new Map<string, string>()
|
||||
for (const field of specs) {
|
||||
tokens.set(field.key, formatSpecValue(field, values[field.key], { forItemName: true }))
|
||||
}
|
||||
tokens.set('category', categoryName)
|
||||
let result = String(template || '').trim()
|
||||
if (result) {
|
||||
result = result.replace(
|
||||
/\{([a-zA-Z][a-zA-Z0-9_]*)\}/g,
|
||||
(_, key: string) => tokens.get(key) || '',
|
||||
)
|
||||
} else {
|
||||
result = specs
|
||||
.filter((field) => field.showInItemName)
|
||||
.map((field) => formatSpecValue(field, values[field.key], { forItemName: true }))
|
||||
.filter(Boolean)
|
||||
.join('')
|
||||
}
|
||||
const normalized = result.replace(/\s+/g, ' ').trim()
|
||||
return normalized || categoryName || '寄卖物品'
|
||||
}
|
||||
|
||||
export type SpecPriceResult = {
|
||||
priceFen: number
|
||||
capFen: number
|
||||
templatePriceFen: number
|
||||
breakdown: Array<{ label: string; amountFen: number }>
|
||||
}
|
||||
|
||||
/** 与后端 computeConsignmentPrice 保持同口径,仅用于配置面板预览。 */
|
||||
export function computeSpecPrice(
|
||||
pricing: ConsignmentPricingConfig,
|
||||
templatePriceFen = 0,
|
||||
): SpecPriceResult {
|
||||
const template = Math.max(0, Math.round(Number(templatePriceFen || 0)))
|
||||
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)
|
||||
breakdown.push({ label: '接单模板价', amountFen: template })
|
||||
if (discount !== 10) {
|
||||
breakdown.push({ label: `${discount} 折`, amountFen: priceFen - template })
|
||||
}
|
||||
}
|
||||
|
||||
priceFen = Math.max(0, Math.round(priceFen))
|
||||
return {
|
||||
priceFen,
|
||||
capFen: priceFen,
|
||||
templatePriceFen: template,
|
||||
breakdown,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,3 +61,37 @@ test('工单页:必须挂载内部库存匹配弹窗,并支持状态 interna
|
||||
assert.ok(shared.includes("internal_matching: '内部匹配中'"), '状态文案必须包含内部匹配中')
|
||||
assert.ok(shared.includes("internal_matching: 'magenta'"), '状态配色必须包含内部匹配中')
|
||||
})
|
||||
|
||||
test('寄卖配置面板:独立功能卡片、空字段初始状态与价格配置', () => {
|
||||
const panel = read('../src/pages/admin/panels/ConsignmentConfigPanel.tsx')
|
||||
for (const label of [
|
||||
'规格字段',
|
||||
'添加字段',
|
||||
'基础信息',
|
||||
'价格配置',
|
||||
'履约保证金',
|
||||
'服务费',
|
||||
'库存数量限制',
|
||||
'匹配设置',
|
||||
'超时与降价提示',
|
||||
'接单模板价打折',
|
||||
'固定价格',
|
||||
'模板折扣',
|
||||
'物品名模板',
|
||||
]) {
|
||||
assert.ok(panel.includes(label), `寄卖配置面板缺少「${label}」`)
|
||||
}
|
||||
assert.ok(!panel.includes('DEFAULT_SPEC_FIELDS'), '面板不能预置任何内置字段')
|
||||
assert.ok(panel.includes('useState<ConsignmentSpecField[]>([])'), '规格字段初始必须为空')
|
||||
assert.ok(panel.includes('specDrafts.map'), '规格字段必须可动态增删/排序')
|
||||
assert.ok(panel.includes('SPEC_FIELD_TYPE_OPTIONS'), '字段类型必须支持 6 种选项')
|
||||
})
|
||||
|
||||
test('打手端:动态规格 + 系统建议价 + 改价不得高于建议价', () => {
|
||||
const page = read('../src/pages/worker/WorkerConsignmentPage.tsx')
|
||||
assert.ok(page.includes('selectedSpecs.map'), '规格必须按配置动态渲染')
|
||||
assert.ok(page.includes('fetchWorkerConsignmentPricePreview'), '必须走动态价格预览')
|
||||
assert.ok(page.includes('不能高于系统建议价'), '前端必须提示改价上限')
|
||||
assert.ok(page.includes('specValues'), '发布必须提交动态规格值')
|
||||
assert.ok(page.includes('config.pricing.allowWorkerPrice'), '改价能力必须按配置')
|
||||
})
|
||||
|
||||
@@ -20,32 +20,26 @@ test('后台全量选项:系统含通用,区服含互通', () => {
|
||||
assert.ok(source.includes("value: '互通'"), '区服选项必须包含「互通」')
|
||||
})
|
||||
|
||||
test('苏尔南发布选项:系统只有苹果/安卓,区服唯一「互通」且为默认值', () => {
|
||||
const source = readFileSync(
|
||||
test('苏尔南默认配置:取消代码内置字段,由迁移 131 提供五个单选字段', () => {
|
||||
const utils = readFileSync(
|
||||
new URL('../src/utils/consignment-spec.ts', import.meta.url).pathname,
|
||||
'utf8',
|
||||
)
|
||||
const surnanSystem = source.slice(
|
||||
source.indexOf('SURNAN_PUBLISH_SYSTEM_OPTIONS'),
|
||||
source.indexOf('SURNAN_PUBLISH_ZONE_OPTIONS'),
|
||||
)
|
||||
assert.ok(!surnanSystem.includes("value: '通用'"), '苏尔南发布不允许再选系统「通用」')
|
||||
assert.ok(source.includes("export const SURNAN_DEFAULT_ZONE = '互通'"), '苏尔南区服默认「互通」')
|
||||
const surnanZone = source.slice(source.indexOf('SURNAN_PUBLISH_ZONE_OPTIONS'))
|
||||
assert.ok(
|
||||
!surnanZone.slice(0, surnanZone.indexOf('\n\n')).includes("value: '微信区'"),
|
||||
'苏尔南发布端区服只有互通一个选项',
|
||||
)
|
||||
assert.ok(!utils.includes('SURNAN_PUBLISH_SYSTEM_OPTIONS'), '前端不再写死苏尔南发布选项')
|
||||
assert.ok(!utils.includes('DEFAULT_SPEC_FIELDS'), '前端不再内置默认 5 个字段')
|
||||
assert.ok(utils.includes('SPEC_FIELD_TYPE_OPTIONS'), '字段类型选项必须集中维护')
|
||||
})
|
||||
|
||||
test('打手端发布表单:用苏尔南选项集 + 区服默认值,不引后台全量', () => {
|
||||
test('打手端发布表单:按寄卖配置动态渲规格,不再回退内置字段', () => {
|
||||
const source = readFileSync(
|
||||
new URL('../src/pages/worker/WorkerConsignmentPage.tsx', import.meta.url).pathname,
|
||||
'utf8',
|
||||
)
|
||||
assert.ok(source.includes('SURNAN_PUBLISH_SYSTEM_OPTIONS'))
|
||||
assert.ok(source.includes('SURNAN_PUBLISH_ZONE_OPTIONS'))
|
||||
assert.ok(source.includes('initialValue={SURNAN_DEFAULT_ZONE}'), '区服默认值要落到表单项上')
|
||||
assert.ok(source.includes('selectedSpecs.map'), '规格字段必须来自配置动态渲染')
|
||||
assert.ok(source.includes('config.specs'), '必须读取寄卖配置的 specs')
|
||||
assert.ok(!source.includes('DEFAULT_SPEC_FIELDS'), '空配置不能回退内置 5 项')
|
||||
assert.ok(source.includes('fetchWorkerConsignmentPricePreview'), '价格必须走动态预览接口')
|
||||
assert.ok(source.includes('buildSpecItemName'), '物品名必须按名称模板生成')
|
||||
assert.ok(!source.includes('[...CONSIGNMENT_'), '发布端不直接引用后台全量选项')
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user