feat(consignment): 寄售仓库按寄卖分类筛选并支持动态规格过滤

This commit is contained in:
2026-11-06 16:12:26 +08:00
parent 1044948c99
commit f68375fa71
16 changed files with 1022 additions and 130 deletions
@@ -22,16 +22,27 @@ function resolveScoreTag(pair: PendingPistolMatchPair) {
export default function ConsignmentPendingMatchModal({
open,
onClose,
categoryId,
categoryName,
}: {
open: boolean
onClose: () => void
/** 只统计该寄卖分类;不传 = 全部 */
categoryId?: number | null
categoryName?: string
}) {
const [keyword, setKeyword] = useState('')
const [includeConflicts, setIncludeConflicts] = useState(false)
const query = useQuery({
queryKey: ['admin-pending-pistol-matches', keyword, includeConflicts],
queryFn: () => fetchPendingPistolMatches({ keyword, includeConflicts, limit: 300 }),
queryKey: ['admin-pending-pistol-matches', categoryId ?? null, keyword, includeConflicts],
queryFn: () =>
fetchPendingPistolMatches({
categoryId: categoryId ?? undefined,
keyword,
includeConflicts,
limit: 300,
}),
enabled: open,
refetchOnWindowFocus: false,
retry: false,
@@ -187,7 +198,7 @@ export default function ConsignmentPendingMatchModal({
return (
<Modal
title="匹配 · 待抢单 × 在库现货"
title={categoryName ? `匹配 · ${categoryName} 待抢单 × 在库现货` : '匹配 · 待抢单 × 在库现货'}
open={open}
width="min(1320px, 94vw)"
footer={null}
@@ -198,11 +209,16 @@ export default function ConsignmentPendingMatchModal({
<Alert
type="info"
showIcon
message="按寄卖配置匹配全部待抢单 × 在库待匹配现货,不直接改单。"
description="有寄卖配置的订单按该分类的规格逐项判定(精确 / 非精确 / 不匹配);拼单订单按剩余份数展示。未配置寄卖的订单仍按旧手枪口径兜底。"
message={
categoryName
? `只统计「${categoryName}」分类下的待抢单 × 在库待匹配现货,不直接改单。`
: '按寄卖配置匹配全部待抢单 × 在库待匹配现货,不直接改单。'
}
description="有寄卖配置的订单按该分类的规格逐项判定(精确 / 非精确 / 不匹配);拼单订单按剩余份数展示。"
/>
<Space wrap size={12}>
{categoryName ? <Tag color="blue">分类:{categoryName}</Tag> : null}
<Tag>待抢单 {data?.pendingOrderCount ?? 0} 单</Tag>
<Tag>在库待匹配 {data?.inStockInventoryCount ?? 0} 件</Tag>
<Tag color="blue">匹配组合 {data?.pairCount ?? 0} 条</Tag>
@@ -267,8 +283,8 @@ export default function ConsignmentPendingMatchModal({
<Empty
description={
includeConflicts
? '没有可展示的配对'
: '没有有分匹配;可勾选「显示 0 分不匹配项」查看冲突'
? '当前分类下没有可展示的配对'
: '当前分类下没有可匹配组合;可勾选「显示 0 分不匹配项」查看冲突'
}
/>
),
@@ -155,6 +155,7 @@ function newCustomSpec(specs: ConsignmentSpecField[]): ConsignmentSpecField {
matchMode: 'exact',
wildcardValues: [],
showInItemName: true,
filterable: false,
sortOrder: (specs.length + 1) * 10,
}
}
@@ -596,11 +597,17 @@ export default function ConsignmentConfigPanel() {
function changeSpecType(index: number, type: ConsignmentSpecFieldType) {
setSpecDrafts((current) =>
current.map((spec, specIndex) =>
specIndex === index
? { ...spec, type, options: isOptionSpecField({ type }) ? spec.options : [] }
: spec,
),
current.map((spec, specIndex) => {
if (specIndex !== index) return spec
const optionField = isOptionSpecField({ type })
return {
...spec,
type,
options: optionField ? spec.options : [],
// 选项类字段默认参与寄售仓库筛选,切回其它类型时保持运营原有选择
filterable: optionField ? spec.filterable !== false : spec.filterable,
}
}),
)
}
@@ -1105,6 +1112,15 @@ export default function ConsignmentConfigPanel() {
}
/>
</div>
<div style={fieldCellStyle} className="order-service-field-required">
<Text type="secondary">支持筛选</Text>
<Switch
checked={spec.filterable === true}
checkedChildren="可筛"
unCheckedChildren="不筛"
onChange={(checked) => updateSpec(index, { filterable: checked })}
/>
</div>
</div>
</div>
))}
@@ -36,7 +36,7 @@ import PercentTable from '@/components/common/PercentTable'
import { ADMIN_DATE_FORMAT } from '@/lib/dayjs'
import { buildAdminTablePagination } from '@/utils/admin-pagination'
import type { TableColumnsType } from 'antd'
import { useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import ConsignmentEvidenceGallery from '@/components/admin/ConsignmentEvidenceGallery'
import ConsignmentPendingMatchModal from '@/components/admin/ConsignmentPendingMatchModal'
@@ -45,6 +45,7 @@ import {
archiveAdminConsignmentInventory,
fetchAdminConsignmentContacts,
fetchAdminConsignmentInventories,
fetchAdminConsignmentInventoryFilterOptions,
fetchAdminConsignmentStats,
returnAdminConsignmentInventory,
toggleAdminConsignmentMatching,
@@ -52,7 +53,11 @@ import {
} from '@/services/admin/consignment'
import { fetchAdminWorkerUsers } from '@/services/admin/worker-platform'
import ConsignmentManualMatchModal from '@/components/admin/ConsignmentManualMatchModal'
import type { ConsignmentInventory, GiftEvidenceReviewStatus } from '@/types/consignment'
import type {
ConsignmentInventory,
ConsignmentInventoryFilterField,
GiftEvidenceReviewStatus,
} from '@/types/consignment'
import { fetchAdminSession } from '@/services/admin/auth'
import { resolveAdminPermission } from '@/utils/admin-auth'
import { formatConsignmentContactSupporter } from '@/utils/consignment-contact'
@@ -62,38 +67,7 @@ import { formatDateTime } from '@/utils/date-time'
const { Text } = Typography
const SYSTEM_OPTIONS = [
{ label: '全部系统', value: '' },
{ label: '苹果 (iOS)', value: '苹果' },
{ label: '安卓 (Android)', value: '安卓' },
{ label: '通用/双端', value: '通用' },
]
const ZONE_OPTIONS = [
{ label: '全部区服', value: '' },
{ label: '微信区', value: '微信区' },
{ label: 'QQ区', value: 'QQ区' },
{ label: '互通', value: '互通' },
]
const SERIES_OPTIONS = [
{ label: '全部系列', value: '' },
{ label: '暗潮', value: '暗潮' },
{ label: '怒嚎', value: '怒嚎' },
{ label: '血宴', value: '血宴' },
]
const DIAMOND_OPTIONS = [
{ label: '全部钻石', value: 0 },
{ label: '1 钻', value: 1 },
{ label: '2 钻', value: 2 },
{ label: '3 钻', value: 3 },
{ label: '4 钻', value: 4 },
{ label: '5 钻', value: 5 },
{ label: '6 钻', value: 6 },
{ label: '7 钻', value: 7 },
]
// 筛选字段与选项全部由「寄卖配置」的已发布版本下发,页面不再维护写死的系统/区服/系列/钻石常量。
const BUYER_COLORS: Record<string, string> = {
小明: 'blue',
小张: 'green',
@@ -147,12 +121,10 @@ function resolveGiftEvidenceEmptyText(record: {
export default function ConsignmentInventoryPanel() {
const { message } = App.useApp()
// 筛选条件
// 筛选条件:分类与规格项全部来自「寄卖配置」已发布版本
const [status, setStatus] = useState<string>('in_stock')
const [system, setSystem] = useState<string>('')
const [serverZone, setServerZone] = useState<string>('')
const [series, setSeries] = useState<string>('')
const [diamondLevel, setDiamondLevel] = useState<number>(0)
const [categoryId, setCategoryId] = useState<number | null>(null)
const [specFilters, setSpecFilters] = useState<Record<string, string>>({})
const [allowMatching, setAllowMatching] = useState<string>('')
const [inboundRange, setInboundRange] = useState<[string, string] | null>(null)
const [keyword, setKeyword] = useState<string>('')
@@ -203,15 +175,64 @@ export default function ConsignmentInventoryPanel() {
value: item.id,
}))
// 寄售仓库筛选项:只含已开启寄卖(已发布配置)的分类及其可筛选规格
const filterOptionsQuery = useQuery({
queryKey: ['admin-consignment-inventory-filter-options'],
queryFn: () => fetchAdminConsignmentInventoryFilterOptions(),
})
const filterCategories = useMemo(
() => filterOptionsQuery.data?.data.items || [],
[filterOptionsQuery.data],
)
const categoryOptions = useMemo(
() =>
filterCategories.map((item) => ({
// 一级分类直接显示分类名;二级分类带上父级,避免同名歧义
label: item.parentName ? `${item.parentName} / ${item.name}` : item.name,
value: item.categoryId,
})),
[filterCategories],
)
// 默认「苏尔南手枪」;该分类未开启寄卖时回退到第一个已开启分类
useEffect(() => {
if (categoryId !== null || filterCategories.length === 0) return
const preferred =
filterCategories.find((item) => item.categoryKey === 'shouqiang') || filterCategories[0]!
setCategoryId(preferred.categoryId)
}, [categoryId, filterCategories])
const activeFilterCategory = filterCategories.find((item) => item.categoryId === categoryId)
const dynamicFilters = activeFilterCategory?.filters || []
// 只提交当前分类实际渲染出来的筛选项,切换分类时旧规格不会带到新分类
const specFilterPayload = useMemo(() => {
const payload: Record<string, { value: string; match: 'exact' | 'contains' }> = {}
for (const field of dynamicFilters) {
const value = specFilters[field.key]
if (value) payload[field.key] = { value, match: field.match }
}
return payload
}, [dynamicFilters, specFilters])
const specFilterParams =
Object.keys(specFilterPayload).length > 0 ? JSON.stringify(specFilterPayload) : undefined
function updateSpecFilter(field: ConsignmentInventoryFilterField, value: string) {
setSpecFilters((current) => {
const next = { ...current }
if (value) next[field.key] = value
else delete next[field.key]
return next
})
setPage(1)
}
// 数据查询
const inventoriesQuery = useQuery({
queryKey: [
'admin-consignment-inventories',
status,
system,
serverZone,
series,
diamondLevel,
categoryId,
specFilterParams,
allowMatching,
inboundRange?.[0],
inboundRange?.[1],
@@ -222,10 +243,8 @@ export default function ConsignmentInventoryPanel() {
queryFn: () =>
fetchAdminConsignmentInventories({
status: status === 'all' ? undefined : status,
system: system || undefined,
serverZone: serverZone || undefined,
series: series || undefined,
diamondLevel: diamondLevel > 0 ? diamondLevel : undefined,
categoryId: categoryId ?? undefined,
specFilters: specFilterParams,
allowMatching: allowMatching === 'yes' ? true : allowMatching === 'no' ? false : undefined,
dateFrom: inboundRange?.[0],
dateTo: inboundRange?.[1],
@@ -235,9 +254,22 @@ export default function ConsignmentInventoryPanel() {
}),
})
// 统计卡片跟随寄售仓库的分类 + 规格 + 入库日期筛选联动
const statsQuery = useQuery({
queryKey: ['admin-consignment-stats'],
queryFn: () => fetchAdminConsignmentStats(),
queryKey: [
'admin-consignment-stats',
categoryId,
specFilterParams,
inboundRange?.[0],
inboundRange?.[1],
],
queryFn: () =>
fetchAdminConsignmentStats({
categoryId: categoryId ?? undefined,
specFilters: specFilterParams,
dateFrom: inboundRange?.[0],
dateTo: inboundRange?.[1],
}),
})
const items = inventoriesQuery.data?.data.items || []
@@ -421,6 +453,23 @@ export default function ConsignmentInventoryPanel() {
</div>
),
},
{
title: '寄卖分类',
key: 'category',
width: 140,
render: (_, record) => (
<Space direction="vertical" size={2}>
<Tag color="blue" style={{ fontSize: 11, padding: '0 6px', marginInlineEnd: 0 }}>
{record.categoryName || '未分类'}
</Tag>
{record.profileId ? (
<Text type="secondary" style={{ fontSize: 11 }}>
寄卖配置 #{record.profileId}
</Text>
) : null}
</Space>
),
},
{
title: '和平精英规格',
key: 'specs',
@@ -813,43 +862,65 @@ export default function ConsignmentInventoryPanel() {
<Radio.Button value="all">全部</Radio.Button>
</Radio.Group>
{/* 寄卖分类:选项与规格筛选项全部来自该类别的已发布寄卖配置 */}
<Select
style={{ width: 130 }}
options={SYSTEM_OPTIONS}
value={system}
onChange={(v) => {
setSystem(v)
setPage(1)
}}
/>
<Select
style={{ width: 110 }}
options={ZONE_OPTIONS}
value={serverZone}
onChange={(v) => {
setServerZone(v)
setPage(1)
}}
/>
<Select
style={{ width: 110 }}
options={SERIES_OPTIONS}
value={series}
onChange={(v) => {
setSeries(v)
setPage(1)
}}
/>
<Select
style={{ width: 100 }}
options={DIAMOND_OPTIONS}
value={diamondLevel}
onChange={(v) => {
setDiamondLevel(v)
style={{ width: 200 }}
loading={filterOptionsQuery.isLoading}
value={categoryId ?? undefined}
placeholder="寄卖分类"
options={categoryOptions}
onChange={(value) => {
setCategoryId(Number(value))
setSpecFilters({})
setPage(1)
}}
/>
{dynamicFilters.map((field) => {
const value = specFilters[field.key]
if (field.type === 'select' || field.type === 'radio') {
return (
<Select
key={field.key}
allowClear
style={{ width: 160 }}
placeholder={field.label}
value={value}
options={field.options}
onChange={(next) => updateSpecFilter(field, next || '')}
/>
)
}
if (field.type === 'number') {
return (
<InputNumber
key={field.key}
style={{ width: 140 }}
placeholder={field.label}
value={value ? Number(value) : null}
onChange={(next) => updateSpecFilter(field, next === null ? '' : String(next))}
/>
)
}
return (
<Input
key={field.key}
allowClear
style={{ width: 160 }}
placeholder={field.type === 'date' ? `${field.label} YYYY-MM-DD` : field.label}
defaultValue={value}
onChange={(event) => {
// 点小叉清空时立即生效;输入内容则在回车/失焦后提交,避免逐字请求
if (!event.target.value) updateSpecFilter(field, '')
}}
onBlur={(event) => updateSpecFilter(field, event.target.value.trim())}
onPressEnter={(event) =>
updateSpecFilter(field, (event.target as HTMLInputElement).value.trim())
}
/>
)
})}
<Select
style={{ width: 120 }}
value={allowMatching}
@@ -1411,9 +1482,11 @@ export default function ConsignmentInventoryPanel() {
onMatched={() => void inventoriesQuery.refetch()}
/>
{/* 全局匹配看板:非拼单待抢单手枪 × 在库待匹配现货 */}
{/* 全局匹配看板:按当前寄卖分类统计待抢单 × 在库待匹配现货 */}
<ConsignmentPendingMatchModal
open={pendingMatchOpen}
categoryId={categoryId}
categoryName={activeFilterCategory?.name}
onClose={() => setPendingMatchOpen(false)}
/>
@@ -3,6 +3,7 @@ import type {
AdminConsignmentProfilesResponse,
ConsignmentContactSupporter,
ConsignmentInventory,
ConsignmentInventoryFilterOptionsResponse,
ConsignmentMatchCandidatesResponse,
ConsignmentProfile,
ConsignmentStatsSummary,
@@ -22,6 +23,16 @@ export function fetchAdminConsignmentProfiles() {
)
}
/**
* 寄售仓库筛选选项:只返回已开启寄卖(已发布配置)的分类,
* 每个分类带出「支持筛选」的规格字段与可选值。
*/
export function fetchAdminConsignmentInventoryFilterOptions() {
return apiGet<ConsignmentInventoryFilterOptionsResponse>(
'/api/v1/admin/worker-platform/consignment/inventory-filter-options',
)
}
export function saveAdminConsignmentProfile(payload: Record<string, unknown>) {
return apiPost<{ profile: ConsignmentProfile }>(
'/api/v1/admin/worker-platform/consignment/profiles',
@@ -127,8 +138,8 @@ export function fetchAdminConsignmentInventories(params?: Record<string, unknown
)
}
export function fetchAdminConsignmentStats() {
return apiGet<ConsignmentStatsSummary>('/api/v1/admin/worker-platform/consignment/stats')
export function fetchAdminConsignmentStats(params?: Record<string, unknown>) {
return apiGet<ConsignmentStatsSummary>('/api/v1/admin/worker-platform/consignment/stats', params)
}
export function getAdminConsignmentInventory(id: number | string) {
@@ -230,8 +241,10 @@ export function fetchInventoryMatchableOrders(
)
}
/** 全局匹配看板:非拼单待抢单手枪 × 在库待匹配现货,按细粒度评分排序。 */
/** 全局匹配看板:待抢单 × 在库待匹配现货,按细粒度评分排序。 */
export function fetchPendingPistolMatches(params?: {
/** 只统计该寄卖分类;不传 = 全部 */
categoryId?: number
keyword?: string
includeConflicts?: boolean
limit?: number
@@ -239,6 +252,7 @@ export function fetchPendingPistolMatches(params?: {
return apiGet<PendingPistolMatchResult>(
'/api/v1/admin/worker-platform/consignment/pending-pistol-matches',
{
categoryId: params?.categoryId,
keyword: params?.keyword?.trim() || undefined,
includeConflicts: params?.includeConflicts ? 'true' : undefined,
limit: params?.limit,
+32
View File
@@ -50,6 +50,8 @@ export type ConsignmentSpecField = {
matchMode: 'exact' | 'minimum' | 'wildcard' | 'ignore'
wildcardValues: string[]
showInItemName: boolean
/** 是否在后台「寄售仓库」作为筛选项(选项类字段默认开启) */
filterable?: boolean
sortOrder: number
}
@@ -459,6 +461,36 @@ export type AdminConsignmentProfilesResponse = {
profiles: ConsignmentProfile[]
}
/** 后台「寄售仓库」筛选项:值来自该分类已发布寄卖配置的规格字段 */
export type ConsignmentInventoryFilterOption = { label: string; value: string }
export type ConsignmentInventoryFilterField = {
key: string
label: string
type: ConsignmentSpecFieldType
/** exact = 等值;contains = 模糊包含(文本字段) */
match: 'exact' | 'contains'
options: ConsignmentInventoryFilterOption[]
}
export type ConsignmentInventoryFilterCategory = {
categoryId: number
categoryKey: string
name: string
parentId: number | null
parentName: string
tabTitle: string
profileId: number
versionNo: number
/** own = 自有已发布配置;inherited = 继承一级分类 */
source: 'own' | 'inherited'
filters: ConsignmentInventoryFilterField[]
}
export type ConsignmentInventoryFilterOptionsResponse = {
items: ConsignmentInventoryFilterCategory[]
}
export type WorkerConsignmentProfile = {
profileId: number
categoryId: number
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
function readSource(relativePath: string) {
// Windows 检出为 CRLF,多行断言里的 \n 会匹配失败,先统一换行
return readFileSync(new URL(relativePath, import.meta.url).pathname, 'utf8').replace(
/\r\n/g,
'\n',
)
}
const panelSource = readSource('../src/pages/admin/panels/ConsignmentInventoryPanel.tsx')
const configSource = readSource('../src/pages/admin/panels/ConsignmentConfigPanel.tsx')
const serviceSource = readSource('../src/services/admin/consignment.ts')
test('寄售仓库:分类下拉只来自寄卖配置,默认苏尔南手枪', () => {
assert.ok(
panelSource.includes('fetchAdminConsignmentInventoryFilterOptions'),
'必须调用寄卖配置下发的筛选项接口',
)
assert.ok(panelSource.includes("categoryKey === 'shouqiang'"), '默认选中苏尔南手枪')
assert.ok(panelSource.includes('categoryId: categoryId ?? undefined'), '分类必须透传给库存查询')
assert.ok(
!panelSource.includes('options={SYSTEM_OPTIONS}') &&
!panelSource.includes('options={DIAMOND_OPTIONS}'),
'旧的写死四筛必须移除',
)
})
test('寄售仓库:筛选项按配置动态渲染,切换分类会清空旧规格', () => {
assert.ok(panelSource.includes('dynamicFilters.map'), '筛选项必须由配置动态渲染')
assert.ok(panelSource.includes('setSpecFilters({})'), '切换分类必须清空上一个分类的规格筛选')
assert.ok(panelSource.includes('specFilters: specFilterParams'), '规格筛选必须透传给库存查询')
assert.ok(panelSource.includes('match: field.match'), '文本规格要带 contains,选项规格走 exact')
})
test('寄售仓库:表格新增「寄卖分类」列', () => {
assert.ok(panelSource.includes("title: '寄卖分类'"), '缺少寄卖分类列')
assert.ok(panelSource.includes('record.categoryName'), '分类列必须展示分类名称')
})
test('寄售仓库:统计卡片跟随分类与规格筛选联动', () => {
assert.ok(
panelSource.includes(
'categoryId: categoryId ?? undefined,\n specFilters: specFilterParams',
),
'统计查询必须带分类与规格筛选',
)
})
test('寄卖配置:规格字段提供「支持筛选」开关并随配置保存', () => {
assert.ok(configSource.includes('支持筛选'), '缺少支持筛选开关')
assert.ok(configSource.includes('filterable: checked'), '开关必须写回配置')
assert.ok(configSource.includes('filterable: false,'), '新建字段默认不参与筛选')
})
test('筛选选项接口:按已开启寄卖的分类返回', () => {
assert.ok(
serviceSource.includes('/worker-platform/consignment/inventory-filter-options'),
'缺少筛选选项接口',
)
})
test('一键查询待匹配订单:按当前寄卖分类查询', () => {
const modalSource = readSource('../src/components/admin/ConsignmentPendingMatchModal.tsx')
assert.ok(modalSource.includes('categoryId: categoryId ?? undefined'), '必须把分类传给待匹配接口')
assert.ok(modalSource.includes('分类:{categoryName}'), '弹窗要标明当前分类')
assert.ok(panelSource.includes('categoryId={categoryId}'), '仓库面板必须把当前分类传给弹窗')
})