diff --git a/apps/backend/src/db/migrations/144_consignment_inventory_category_backfill.sql b/apps/backend/src/db/migrations/144_consignment_inventory_category_backfill.sql new file mode 100644 index 00000000..0f7cff8c --- /dev/null +++ b/apps/backend/src/db/migrations/144_consignment_inventory_category_backfill.sql @@ -0,0 +1,66 @@ +-- 144_consignment_inventory_category_backfill.sql +-- 寄售仓库按「寄卖配置分类」筛选的前置数据修复。 +-- +-- 背景:分类级寄卖配置(142)上线前入库的提报与库存没有写 category_id/profile_id, +-- 线上 784 条库存、877 条提报全部为空;不补的话「按分类筛选」在老数据上永远是空列表。 +-- +-- 口径:这批历史数据全部是苏尔南手枪(item_name 含「苏尔南」,spec_values_json 固定 +-- system/serverZone/series/diamondLevel/brillianceCount 五个键),因此按该分类的 +-- 已发布配置回填,幂等且只影响 category_id 为空的记录。 + +DO $migration$ +DECLARE + v_category_id BIGINT; + v_profile_id BIGINT; + v_version_id BIGINT; + v_snapshot JSONB; +BEGIN + SELECT id INTO v_category_id + FROM work_categories + WHERE category_key = 'shouqiang' + LIMIT 1; + + IF v_category_id IS NULL THEN + RETURN; + END IF; + + SELECT p.id, p.current_version_id, v.config_json + INTO v_profile_id, v_version_id, v_snapshot + FROM consignment_profiles p + LEFT JOIN consignment_profile_versions v ON v.id = p.current_version_id + WHERE p.category_id = v_category_id + AND p.status = 'published' + LIMIT 1; + + IF v_profile_id IS NULL THEN + RETURN; + END IF; + + UPDATE consignment_inventories + SET category_id = v_category_id, + profile_id = COALESCE(profile_id, v_profile_id), + profile_version_id = COALESCE(profile_version_id, v_version_id), + profile_snapshot_json = COALESCE(profile_snapshot_json, v_snapshot) + WHERE category_id IS NULL + AND ( + item_name LIKE '%苏尔南%' + OR (spec_values_json ? 'series' AND spec_values_json ? 'diamondLevel') + ); + + UPDATE consignment_submissions + SET category_id = v_category_id, + profile_id = COALESCE(profile_id, v_profile_id), + profile_version_id = COALESCE(profile_version_id, v_version_id), + profile_snapshot_json = COALESCE(profile_snapshot_json, v_snapshot) + WHERE category_id IS NULL + AND ( + item_name LIKE '%苏尔南%' + OR (spec_values_json ? 'series' AND spec_values_json ? 'diamondLevel') + ); +END $migration$; + +CREATE INDEX IF NOT EXISTS idx_consignment_inventories_category + ON consignment_inventories (category_id, status); + +COMMENT ON COLUMN consignment_inventories.category_id IS + '寄卖配置分类:寄售仓库按分类筛选的依据;142 之前的历史数据由 144 回填'; diff --git a/apps/backend/src/repositories/worker-platform/consignment-repo.ts b/apps/backend/src/repositories/worker-platform/consignment-repo.ts index 284b5f12..64c3c1f8 100644 --- a/apps/backend/src/repositories/worker-platform/consignment-repo.ts +++ b/apps/backend/src/repositories/worker-platform/consignment-repo.ts @@ -150,6 +150,58 @@ export type ConsignmentStatsSummary = { bySeries: Array<{ series: string; diamond_level: number; count: number }> } +/** 寄售仓库按寄卖配置动态规格筛选:key 走白名单,值一律参数绑定。 */ +export type ConsignmentInventorySpecFilter = { + key: string + value: string + /** exact = 等值(选项/数字/日期);contains = 模糊包含(文本) */ + match?: 'exact' | 'contains' +} + +/** + * 内置规格 key → 库存旧列。 + * 老库存的 spec_values_json 可能为空(动态规格上线前入库),此时按旧列兜底,避免筛不到历史货。 + */ +const LEGACY_SPEC_COLUMN_BY_KEY: Record = { + system: 'ci.system', + serverZone: 'ci.server_zone', + series: 'ci.series', + diamondLevel: 'ci.diamond_level', + brillianceCount: 'ci.brilliance_count', +} + +const SPEC_FILTER_KEY_PATTERN = /^[A-Za-z][A-Za-z0-9_]{0,39}$/ + +/** 把动态规格筛选追加到 conditions(values 同步 push,保持占位符顺序)。 */ +export function buildConsignmentSpecFilterConditions( + specFilters: ConsignmentInventorySpecFilter[] | undefined, + values: unknown[], + conditions: string[], +) { + for (const filter of specFilters || []) { + const key = String(filter?.key || '').trim() + const value = String(filter?.value ?? '').trim() + if (!key || !value || !SPEC_FILTER_KEY_PATTERN.test(key)) continue + + values.push(key) + const keyIndex = values.length + if (filter.match === 'contains') { + values.push(`%${value}%`) + conditions.push(`ci.spec_values_json ->> $${keyIndex} ILIKE $${values.length}`) + continue + } + + values.push(value) + const valueIndex = values.length + const legacyColumn = LEGACY_SPEC_COLUMN_BY_KEY[key] + conditions.push( + legacyColumn + ? `COALESCE(NULLIF(ci.spec_values_json ->> $${keyIndex}, ''), ${legacyColumn}::text) = $${valueIndex}` + : `ci.spec_values_json ->> $${keyIndex} = $${valueIndex}`, + ) + } +} + export async function listConsignmentInventories(filters: { status?: string | undefined buyerName?: string | undefined @@ -165,6 +217,8 @@ export async function listConsignmentInventories(filters: { keyword?: string | undefined dateFrom?: string | undefined dateTo?: string | undefined + /** 按寄卖配置动态规格筛选(key -> 选中值);key 为内置规格时回退库存旧列 */ + specFilters?: ConsignmentInventorySpecFilter[] | undefined page?: number | undefined pageSize?: number | undefined }): Promise<{ items: ConsignmentInventoryRow[]; total: number }> { @@ -215,6 +269,7 @@ export async function listConsignmentInventories(filters: { values.push(filters.allowMatching) conditions.push(`ci.allow_matching = $${values.length}`) } + buildConsignmentSpecFilterConditions(filters.specFilters, values, conditions) if (filters.keyword) { values.push(`%${filters.keyword.trim()}%`) conditions.push( @@ -290,21 +345,56 @@ export async function listConsignmentInventories(filters: { return { items: listRes.rows, total } } -export async function getConsignmentInventoryStats(): Promise { +export async function getConsignmentInventoryStats( + filters: { + categoryId?: number | undefined + specFilters?: ConsignmentInventorySpecFilter[] | undefined + dateFrom?: string | undefined + dateTo?: string | undefined + } = {}, +): Promise { + const conditions: string[] = [] + const values: unknown[] = [] + + if (filters.categoryId) { + values.push(filters.categoryId) + conditions.push(`ci.category_id = $${values.length}`) + } + buildConsignmentSpecFilterConditions(filters.specFilters, values, conditions) + if (filters.dateFrom) { + values.push(filters.dateFrom) + conditions.push( + `ci.inbound_at >= ($${values.length}::date::timestamp AT TIME ZONE 'Asia/Shanghai')`, + ) + } + if (filters.dateTo) { + values.push(filters.dateTo) + conditions.push( + `ci.inbound_at < (($${values.length}::date + INTERVAL '1 day')::timestamp AT TIME ZONE 'Asia/Shanghai')`, + ) + } + + /** 统计口径跟着寄售仓库的筛选走:分类 + 动态规格 + 入库日期。 */ + const whereWith = (...extra: string[]) => { + const all = [...conditions, ...extra] + return all.length > 0 ? `WHERE ${all.join(' AND ')}` : '' + } + const totalsSql = ` SELECT - COALESCE(SUM(CASE WHEN status = 'in_stock' THEN 1 ELSE 0 END), 0)::integer AS in_stock_count, - COALESCE(SUM(CASE WHEN status = 'in_stock' THEN cost_price_fen ELSE 0 END), 0)::integer AS in_stock_cost_fen, - COALESCE(SUM(CASE WHEN status = 'consumed' THEN 1 ELSE 0 END), 0)::integer AS consumed_count, - COALESCE(SUM(CASE WHEN status = 'consumed' THEN cost_price_fen ELSE 0 END), 0)::integer AS consumed_cost_fen - FROM consignment_inventories + COALESCE(SUM(CASE WHEN ci.status = 'in_stock' THEN 1 ELSE 0 END), 0)::integer AS in_stock_count, + COALESCE(SUM(CASE WHEN ci.status = 'in_stock' THEN ci.cost_price_fen ELSE 0 END), 0)::integer AS in_stock_cost_fen, + COALESCE(SUM(CASE WHEN ci.status = 'consumed' THEN 1 ELSE 0 END), 0)::integer AS consumed_count, + COALESCE(SUM(CASE WHEN ci.status = 'consumed' THEN ci.cost_price_fen ELSE 0 END), 0)::integer AS consumed_cost_fen + FROM consignment_inventories ci + ${whereWith()} ` const totalsRes = await query<{ in_stock_count: number in_stock_cost_fen: number consumed_count: number consumed_cost_fen: number - }>(totalsSql) + }>(totalsSql, values) const totals = totalsRes.rows[0] || { in_stock_count: 0, in_stock_cost_fen: 0, @@ -314,12 +404,13 @@ export async function getConsignmentInventoryStats(): Promise(buyerSql) + }>(buyerSql, values) const shopSql = ` SELECT - COALESCE(NULLIF(shop_name, ''), '其他') AS shop_name, + COALESCE(NULLIF(ci.shop_name, ''), '其他') AS shop_name, COUNT(*)::integer AS count - FROM consignment_inventories - WHERE status = 'consumed' AND shop_name <> '' - GROUP BY shop_name + FROM consignment_inventories ci + ${whereWith(`ci.status = 'consumed'`, `ci.shop_name <> ''`)} + GROUP BY ci.shop_name ORDER BY count DESC LIMIT 20 ` - const shopRes = await query<{ shop_name: string; count: number }>(shopSql) + const shopRes = await query<{ shop_name: string; count: number }>(shopSql, values) const batchSql = ` SELECT - COALESCE(NULLIF(inbound_batch, ''), '未分类') AS inbound_batch, + COALESCE(NULLIF(ci.inbound_batch, ''), '未分类') AS inbound_batch, COUNT(*)::integer AS count, - SUM(cost_price_fen)::integer AS total_cost_fen - FROM consignment_inventories - GROUP BY inbound_batch + SUM(ci.cost_price_fen)::integer AS total_cost_fen + FROM consignment_inventories ci + ${whereWith()} + GROUP BY ci.inbound_batch ORDER BY count DESC LIMIT 20 ` const batchRes = await query<{ inbound_batch: string; count: number; total_cost_fen: number }>( batchSql, + values, ) const seriesSql = ` SELECT - COALESCE(NULLIF(series, ''), '未注明') AS series, - diamond_level, + COALESCE(NULLIF(ci.series, ''), '未注明') AS series, + ci.diamond_level, COUNT(*)::integer AS count - FROM consignment_inventories - WHERE status = 'in_stock' - GROUP BY series, diamond_level + FROM consignment_inventories ci + ${whereWith(`ci.status = 'in_stock'`)} + GROUP BY ci.series, ci.diamond_level ORDER BY count DESC LIMIT 20 ` - const seriesRes = await query<{ series: string; diamond_level: number; count: number }>(seriesSql) + const seriesRes = await query<{ series: string; diamond_level: number; count: number }>( + seriesSql, + values, + ) return { totalInStockCount: totals.in_stock_count, diff --git a/apps/backend/src/routes/admin/worker-platform/consignment.ts b/apps/backend/src/routes/admin/worker-platform/consignment.ts index cfcab6bc..44154433 100644 --- a/apps/backend/src/routes/admin/worker-platform/consignment.ts +++ b/apps/backend/src/routes/admin/worker-platform/consignment.ts @@ -28,6 +28,7 @@ import { } from '../../../services/worker-platform/consignment-fulfillment-service.js' import { archiveAdminConsignmentProfile, + listAdminConsignmentInventoryFilterOptions, listAdminConsignmentProfiles, publishAdminConsignmentProfile, saveAdminConsignmentProfile, @@ -152,6 +153,18 @@ router.get( ) // ---------------- 库存管理与统计 ---------------- + +// 寄售仓库筛选选项:只返回已开启寄卖(已发布配置)的分类及其可筛选规格。 +router.get( + '/worker-platform/consignment/inventory-filter-options', + requireAdminPermission('consignment.view'), + createJsonHandler(() => listAdminConsignmentInventoryFilterOptions(), { + successMessage: 'ok', + errorMessage: '读取寄售仓库筛选项失败', + scope: '[admin/consignment/inventory-filter-options]', + }), +) + router.get( '/worker-platform/consignment/inventories', requireAdminPermission('consignment.view'), @@ -165,7 +178,7 @@ router.get( router.get( '/worker-platform/consignment/stats', requireAdminPermission('consignment.view'), - createJsonHandler(() => fetchAdminConsignmentStats(), { + createJsonHandler((req) => fetchAdminConsignmentStats(req.query), { successMessage: 'ok', errorMessage: '读取寄卖统计失败', scope: '[admin/consignment/stats]', @@ -384,6 +397,7 @@ router.get( createJsonHandler( (req) => listPendingConsignmentMatches({ + categoryId: Number(req.query.categoryId || 0) || undefined, keyword: String(req.query.keyword || ''), includeConflicts: String(req.query.includeConflicts || '') === 'true', limit: Number(req.query.limit || 0) || undefined, diff --git a/apps/backend/src/services/worker-platform/consignment-admin-service.ts b/apps/backend/src/services/worker-platform/consignment-admin-service.ts index b8cf32c4..3ddaf136 100644 --- a/apps/backend/src/services/worker-platform/consignment-admin-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-admin-service.ts @@ -1,6 +1,7 @@ import { withTransaction } from '../../db/client.js' import { type ConsignmentInventoryRow, + type ConsignmentInventorySpecFilter, type ConsignmentSubmissionRow, approveConsignmentSubmissionWithInbound, getConsignmentInventoryById, @@ -403,6 +404,7 @@ export async function fetchAdminConsignmentInventories(query: JsonObject = {}) { const dateTo = normalizeInventoryDate(query.dateTo) const page = query.page ? Number(query.page) : 1 const pageSize = query.pageSize ? Number(query.pageSize) : 20 + const specFilters = normalizeInventorySpecFilters(query.specFilters) const res = await listConsignmentInventories({ status, @@ -415,6 +417,7 @@ export async function fetchAdminConsignmentInventories(query: JsonObject = {}) { series, diamondLevel, allowMatching, + specFilters, keyword, dateFrom, dateTo, @@ -442,8 +445,45 @@ export function normalizeInventoryDate(rawValue: unknown) { : normalized } -export async function fetchAdminConsignmentStats() { - return getConsignmentInventoryStats() +export async function fetchAdminConsignmentStats(query: JsonObject = {}) { + const categoryId = query.categoryId ? Number(query.categoryId) : 0 + return getConsignmentInventoryStats({ + categoryId: Number.isInteger(categoryId) && categoryId > 0 ? categoryId : undefined, + specFilters: normalizeInventorySpecFilters(query.specFilters), + dateFrom: normalizeInventoryDate(query.dateFrom), + dateTo: normalizeInventoryDate(query.dateTo), + }) +} + +/** + * 寄售仓库动态规格筛选:前端传 JSON 字符串,如 `{"system":"苹果","series":"暗潮"}`; + * 也接受 `{ "key": { "value": "苹果", "match": "contains" } }` 指定文本模糊匹配。 + * key 的合法性由仓储层白名单校验,这里只做结构收敛。 + */ +export function normalizeInventorySpecFilters(rawValue: unknown): ConsignmentInventorySpecFilter[] { + const parsed = typeof rawValue === 'string' ? safeParseJson(rawValue) : rawValue + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return [] + + const filters: ConsignmentInventorySpecFilter[] = [] + for (const [rawKey, rawFilter] of Object.entries(parsed as Record)) { + const key = String(rawKey || '').trim() + if (!key) continue + + const container = + rawFilter && typeof rawFilter === 'object' && !Array.isArray(rawFilter) + ? (rawFilter as Record) + : null + const value = String((container ? container.value : rawFilter) ?? '').trim() + if (!value) continue + + filters.push({ + key, + value, + match: container?.match === 'contains' ? 'contains' : 'exact', + }) + if (filters.length >= 20) break + } + return filters } export async function getAdminConsignmentInventory(id: number | string) { diff --git a/apps/backend/src/services/worker-platform/consignment-inventory-filter.test.ts b/apps/backend/src/services/worker-platform/consignment-inventory-filter.test.ts new file mode 100644 index 00000000..b605c3dc --- /dev/null +++ b/apps/backend/src/services/worker-platform/consignment-inventory-filter.test.ts @@ -0,0 +1,200 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +import { normalizeInventorySpecFilters } from './consignment-admin-service.js' +import { + buildConsignmentInventoryFilterFields, + normalizeConsignmentProfileConfig, + sortConsignmentInventoryFilterCategories, +} from './consignment-profile-service.js' +import { normalizeConsignmentSpecs } from './consignment-spec-service.js' +import { buildConsignmentSpecFilterConditions } from '../../repositories/worker-platform/consignment-repo.js' + +function buildSpec(overrides: Record = {}) { + return { + key: 'color', + label: '颜色', + type: 'select', + options: [ + { label: '红', value: 'red', enabled: true }, + { label: '蓝', value: 'blue', enabled: false }, + ], + sortOrder: 10, + ...overrides, + } +} + +// --------------------------------------------------------------------------- +// 1. 筛选字段生成 +// --------------------------------------------------------------------------- + +test('寄售仓库筛选字段:只保留开启筛选的非长文本字段,并按 sortOrder 排序', () => { + const config = normalizeConsignmentProfileConfig({ + specs: [ + buildSpec({ key: 'second', label: '第二', filterable: true, sortOrder: 20 }), + buildSpec({ key: 'first', label: '第一', filterable: true, sortOrder: 10 }), + buildSpec({ key: 'hidden', label: '不筛', filterable: false, sortOrder: 30 }), + buildSpec({ + key: 'memo', + label: '长文本', + type: 'textarea', + filterable: true, + sortOrder: 40, + }), + ], + }) + + const fields = buildConsignmentInventoryFilterFields(config) + assert.deepEqual( + fields.map((field) => field.key), + ['first', 'second'], + ) +}) + +test('寄售仓库筛选字段:选项类只带启用中的选项,文本类走模糊匹配', () => { + const config = normalizeConsignmentProfileConfig({ + specs: [ + buildSpec({ key: 'color', filterable: true, sortOrder: 10 }), + buildSpec({ + key: 'note', + label: '备注', + type: 'text', + options: [], + filterable: true, + sortOrder: 20, + }), + ], + }) + + const fields = buildConsignmentInventoryFilterFields(config) + const color = fields.find((field) => field.key === 'color') + assert.deepEqual(color?.options, [{ label: '红', value: 'red' }]) + assert.equal(color?.match, 'exact') + + const note = fields.find((field) => field.key === 'note') + assert.equal(note?.match, 'contains') + assert.deepEqual(note?.options, []) +}) + +// --------------------------------------------------------------------------- +// 2. 规格筛选 SQL +// --------------------------------------------------------------------------- + +test('规格筛选 SQL:内置 key 回退库存旧列,避免历史库存筛不到', () => { + const values: unknown[] = [] + const conditions: string[] = [] + buildConsignmentSpecFilterConditions([{ key: 'system', value: '苹果' }], values, conditions) + + assert.deepEqual(values, ['system', '苹果']) + assert.equal( + conditions[0], + `COALESCE(NULLIF(ci.spec_values_json ->> $1, ''), ci.system::text) = $2`, + ) +}) + +test('规格筛选 SQL:非内置 key 只查 spec_values_json', () => { + const values: unknown[] = [] + const conditions: string[] = [] + buildConsignmentSpecFilterConditions([{ key: 'color', value: 'red' }], values, conditions) + + assert.equal(conditions[0], `ci.spec_values_json ->> $1 = $2`) +}) + +test('规格筛选 SQL:文本字段用 ILIKE 模糊匹配', () => { + const values: unknown[] = [] + const conditions: string[] = [] + buildConsignmentSpecFilterConditions( + [{ key: 'note', value: '暗潮', match: 'contains' }], + values, + conditions, + ) + + assert.deepEqual(values, ['note', '%暗潮%']) + assert.equal(conditions[0], `ci.spec_values_json ->> $1 ILIKE $2`) +}) + +test('规格筛选 SQL:非法 key 与空值一律忽略', () => { + const values: unknown[] = [] + const conditions: string[] = [] + buildConsignmentSpecFilterConditions( + [ + { key: 'bad-key', value: 'x' }, + { key: 'ok', value: ' ' }, + ], + values, + conditions, + ) + + assert.equal(values.length, 0) + assert.equal(conditions.length, 0) +}) + +// --------------------------------------------------------------------------- +// 3. 请求参数归一化 +// --------------------------------------------------------------------------- + +test('规格筛选参数:字符串 JSON 归一化,含模糊匹配声明', () => { + assert.deepEqual( + normalizeInventorySpecFilters('{"system":"苹果","note":{"value":"暗潮","match":"contains"}}'), + [ + { key: 'system', value: '苹果', match: 'exact' }, + { key: 'note', value: '暗潮', match: 'contains' }, + ], + ) +}) + +test('规格筛选参数:数组、空值与纯文本一律丢弃', () => { + assert.deepEqual(normalizeInventorySpecFilters(['system']), []) + assert.deepEqual(normalizeInventorySpecFilters('{"system":" "}'), []) + assert.deepEqual(normalizeInventorySpecFilters(undefined), []) +}) + +// --------------------------------------------------------------------------- +// 4. 筛选选项接口口径 +// --------------------------------------------------------------------------- + +test('寄售仓库筛选选项:只取已发布且分类启用的配置,并保留继承口径', async () => { + const source = await readFile( + new URL('./consignment-profile-service.ts', import.meta.url), + 'utf8', + ) + const body = source.slice( + source.indexOf('export async function listAdminConsignmentInventoryFilterOptions'), + ) + + assert.ok(body.includes(`p.status = 'published'`), '只允许已发布配置') + assert.ok(body.includes(`c.status = 'active'`), '停用分类不出现') + assert.ok(body.includes('p.current_version_id IS NOT NULL'), '必须指向已生成版本') + assert.ok(body.includes('inheritedProfile'), '二级分类要能继承一级分类配置') + assert.ok(body.includes('inventoryCountByCategoryId'), '无库存的继承子分类不应出现') +}) + +test('规格字段 filterable:选项类默认开启,其它类型默认关闭,显式值优先', () => { + const [optionField] = normalizeConsignmentSpecs([buildSpec()]) + assert.equal(optionField?.filterable, true) + + const [textField] = normalizeConsignmentSpecs([buildSpec({ key: 'note', type: 'text' })]) + assert.equal(textField?.filterable, false) + + const [disabled] = normalizeConsignmentSpecs([buildSpec({ filterable: false })]) + assert.equal(disabled?.filterable, false) + + const [enabledText] = normalizeConsignmentSpecs([ + buildSpec({ key: 'note', type: 'text', filterable: true }), + ]) + assert.equal(enabledText?.filterable, true) +}) + +test('筛选项排序:苏尔南手枪固定排第一,其余保持配置顺序', () => { + const sorted = sortConsignmentInventoryFilterCategories([ + { categoryKey: 'default', name: '密钥' }, + { categoryKey: 'shouqiang', name: '苏尔南手枪' }, + { categoryKey: 'other', name: '其它' }, + ]) + + assert.deepEqual( + sorted.map((item) => item.name), + ['苏尔南手枪', '密钥', '其它'], + ) +}) diff --git a/apps/backend/src/services/worker-platform/consignment-pending-match-service.test.ts b/apps/backend/src/services/worker-platform/consignment-pending-match-service.test.ts index 91fb26c5..e7a156c6 100644 --- a/apps/backend/src/services/worker-platform/consignment-pending-match-service.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-pending-match-service.test.ts @@ -380,3 +380,70 @@ test('看板新口径:无寄卖配置的订单仍按旧手枪口径兜底', () assert.equal(result.pairs[0]?.profileId, null) assert.equal(result.pairs[0]?.score, 100) }) + +test('看板按分类筛选:只保留该寄卖配置分类的订单', () => { + const result = buildPendingConsignmentMatchPairs({ + orders: [ + buildCustomOrder({ id: 401, work_order_no: 'WK401' }), + buildCustomOrder({ id: 402, work_order_no: 'WK402' }), + ], + inventories: [buildCustomInventory({ id: 411 }), buildCustomInventory({ id: 412 })], + profileByOrderId: new Map([ + [401, buildProfile({ profileId: 99, categoryId: 9 })], + [402, buildProfile({ profileId: 100, categoryId: 10 })], + ]), + categoryId: 9, + }) + + assert.equal(result.pendingOrderCount, 1, '其它分类的订单不应参与统计') + assert.ok(result.pairs.every((pair) => pair.workOrderId === 401)) +}) + +test('看板按分类筛选:排除其它分类的库存', () => { + const result = buildPendingConsignmentMatchPairs({ + orders: [buildCustomOrder({ id: 421 })], + inventories: [ + buildCustomInventory({ id: 431, category_id: 9 }), + buildCustomInventory({ id: 432, category_id: 10 }), + ], + profileByOrderId: new Map([[421, buildProfile({ profileId: 99, categoryId: 9 })]]), + categoryId: 9, + }) + + assert.deepEqual( + result.pairs.map((pair) => pair.inventoryId), + [431], + ) + assert.equal(result.inStockInventoryCount, 1) +}) + +test('看板按分类筛选:无寄卖配置的老订单按工单分类过滤', () => { + const result = buildPendingConsignmentMatchPairs({ + orders: [ + buildOrder({ id: 441, category_id: 4 }), + buildOrder({ id: 442, work_order_no: 'WK442', category_id: 1 }), + ], + inventories: [buildInventory({ id: 451, category_id: 4 })], + profileByOrderId: new Map([ + [441, null], + [442, null], + ]), + categoryId: 4, + }) + + assert.deepEqual( + result.pairs.map((pair) => pair.workOrderId), + [441], + ) + assert.equal(result.pendingOrderCount, 1) +}) + +test('看板按分类筛选:不传分类时保持原有全量口径', () => { + const result = buildPendingConsignmentMatchPairs({ + orders: [buildCustomOrder({ id: 461 })], + inventories: [buildCustomInventory({ id: 471, category_id: 10 })], + profileByOrderId: new Map([[461, buildProfile({ profileId: 99, categoryId: 9 })]]), + }) + + assert.equal(result.pendingOrderCount, 1) +}) diff --git a/apps/backend/src/services/worker-platform/consignment-pending-match-service.ts b/apps/backend/src/services/worker-platform/consignment-pending-match-service.ts index 0c73b2ea..7f5a3e93 100644 --- a/apps/backend/src/services/worker-platform/consignment-pending-match-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-pending-match-service.ts @@ -251,11 +251,14 @@ export function buildPendingConsignmentMatchPairs(input: { orders: WorkOrderRow[] inventories: ConsignmentInventoryRow[] profileByOrderId: Map + /** 只统计该寄卖分类下的订单与库存;0 / 未传 = 全部 */ + categoryId?: number | undefined keyword?: string | undefined includeConflicts?: boolean | undefined limit?: number | undefined ordersTruncated?: boolean | undefined }): PendingConsignmentMatchResult { + const categoryId = Math.max(0, Math.floor(Number(input.categoryId) || 0)) const keyword = String(input.keyword || '') .trim() .toLowerCase() @@ -291,12 +294,20 @@ export function buildPendingConsignmentMatchPairs(input: { const remainingQuantity = profile && order.sharing_enabled ? Math.max(0, totalQuantity - activeQuantity) : 1 if (remainingQuantity <= 0) continue + // 按寄卖分类筛选:有配置的订单看配置归属,没配置的老订单回退工单自身分类 + if (categoryId > 0) { + const orderCategoryId = profile ? profile.categoryId : Number(order.category_id || 0) + if (orderCategoryId !== categoryId) continue + } preparedOrders.push({ order, profile, remainingQuantity, totalQuantity }) } const inventories = input.inventories.filter((inventory) => { if (inventory.status !== 'in_stock' || inventory.allow_matching !== true) return false - return Number(inventory.remaining_quantity ?? 1) > 0 + if (Number(inventory.remaining_quantity ?? 1) <= 0) return false + // 库存必须属于当前寄卖分类;没有分类的历史货无法判断归属,按分类看板时排除 + if (categoryId > 0 && Number(inventory.category_id || 0) !== categoryId) return false + return true }) const pairs: PendingConsignmentMatchPair[] = [] @@ -412,6 +423,8 @@ export function buildPendingConsignmentMatchPairs(input: { /** 后台「寄售仓库 → 匹配」:待抢单 × 在库待匹配现货。 */ export async function listPendingConsignmentMatches( input: { + /** 只统计该寄卖分类;0 / 未传 = 全部 */ + categoryId?: number | undefined keyword?: string | undefined includeConflicts?: boolean | undefined limit?: number | undefined @@ -446,6 +459,7 @@ export async function listPendingConsignmentMatches( orders: items, inventories, profileByOrderId, + categoryId: input.categoryId, keyword: input.keyword, includeConflicts: input.includeConflicts, limit: input.limit, diff --git a/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts b/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts index 531e0d17..a1b937dd 100644 --- a/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-profile-service.test.ts @@ -106,3 +106,16 @@ test('合并迁移 142 必须包含寄卖配置、库存数量/售价与分配 assert.ok(source.includes(fragment), `合并迁移 142 缺少 ${fragment}`) } }) + +test('打手端寄卖分类:苏尔南手枪固定排第一', async () => { + const source = await readFile( + new URL('./consignment-profile-service.ts', import.meta.url), + 'utf8', + ) + const body = source.slice(source.indexOf('export async function listWorkerConsignmentProfiles')) + + assert.ok( + body.includes("ORDER BY (c.category_key = 'shouqiang') DESC"), + '苏尔南手枪必须排在打手端分类列表第一位', + ) +}) diff --git a/apps/backend/src/services/worker-platform/consignment-profile-service.ts b/apps/backend/src/services/worker-platform/consignment-profile-service.ts index 391814c1..c6ff00fa 100644 --- a/apps/backend/src/services/worker-platform/consignment-profile-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-profile-service.ts @@ -509,7 +509,8 @@ export async function listWorkerConsignmentProfiles() { ` ${PROFILE_SELECT} WHERE p.status = 'published' AND c.status = 'active' - ORDER BY c.sort_order ASC, p.id ASC + -- 苏尔南手枪固定排第一位,其余按分类配置顺序 + ORDER BY (c.category_key = 'shouqiang') DESC, c.sort_order ASC, p.id ASC `, ) return result.rows.map((row) => { @@ -528,6 +529,154 @@ export async function listWorkerConsignmentProfiles() { }) } +/** 后台「寄售仓库」动态筛选项:值来自该分类已发布配置的规格字段。 */ +export type ConsignmentInventoryFilterOption = { label: string; value: string } + +export type ConsignmentInventoryFilterField = { + key: string + label: string + type: string + /** 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 function buildConsignmentInventoryFilterFields( + config: ConsignmentProfileConfig, +): ConsignmentInventoryFilterField[] { + const fields: ConsignmentInventoryFilterField[] = [] + const seen = new Set() + for (const spec of config.specs) { + if (!spec.filterable || spec.type === 'textarea') continue + if (seen.has(spec.key)) continue + seen.add(spec.key) + fields.push({ + key: spec.key, + label: spec.label || spec.key, + type: spec.type, + match: spec.type === 'text' ? 'contains' : 'exact', + options: + spec.type === 'select' || spec.type === 'radio' + ? spec.options + .filter((option) => option.enabled) + .map((option) => ({ + label: option.label, + value: option.value, + })) + : [], + }) + } + return fields.sort((left, right) => { + const leftSpec = config.specs.find((spec) => spec.key === left.key) + const rightSpec = config.specs.find((spec) => spec.key === right.key) + return (leftSpec?.sortOrder || 0) - (rightSpec?.sortOrder || 0) + }) +} + +/** 苏尔南手枪固定在筛选项第一位;其余分类保持配置里的排序。 */ +export function sortConsignmentInventoryFilterCategories( + items: T[], +): T[] { + return [...items].sort((left, right) => { + const leftRank = left.categoryKey === 'shouqiang' ? 0 : 1 + const rightRank = right.categoryKey === 'shouqiang' ? 0 : 1 + return leftRank - rightRank + }) +} + +/** + * 后台「寄售仓库」筛选选项:只返回已开启寄卖的分类。 + * + * 口径与打手端 tab / 工单匹配保持一致: + * - 分类必须是启用状态; + * - 分类自有已发布配置优先,没有则继承一级分类的已发布配置; + * - 草稿、归档、停用分类一律不出现。 + */ +export async function listAdminConsignmentInventoryFilterOptions(): Promise<{ + items: ConsignmentInventoryFilterCategory[] +}> { + const [categories, profiles] = await Promise.all([ + listAllWorkCategories(), + query( + ` + ${PROFILE_SELECT} + WHERE p.status = 'published' AND c.status = 'active' AND p.current_version_id IS NOT NULL + ORDER BY c.sort_order ASC, p.id ASC + `, + ), + ]) + + // 继承型二级分类只有真的有库存时才出现在筛选项里: + // 否则会出现「暗潮 / 怒嚎 / 血宴」这类系列子分类,点进去永远是空列表。 + const inventoryCounts = await query<{ category_id: number; total: number }>( + `SELECT category_id, COUNT(*)::integer AS total + FROM consignment_inventories + WHERE category_id IS NOT NULL + GROUP BY category_id`, + ) + const inventoryCountByCategoryId = new Map() + for (const row of inventoryCounts.rows) { + inventoryCountByCategoryId.set(Number(row.category_id), Number(row.total || 0)) + } + + const profileByCategoryId = new Map() + for (const profile of profiles.rows) { + profileByCategoryId.set(Number(profile.category_id), profile) + } + const categoryById = new Map() + for (const category of categories) { + categoryById.set(Number(category.id), category) + } + + const items: ConsignmentInventoryFilterCategory[] = [] + for (const category of categories) { + if (category.status !== 'active') continue + + const ownProfile = profileByCategoryId.get(Number(category.id)) + const parentCategory = category.parent_id + ? categoryById.get(Number(category.parent_id)) + : undefined + const inheritedProfile = + !ownProfile && parentCategory && parentCategory.status === 'active' + ? profileByCategoryId.get(Number(parentCategory.id)) + : undefined + const effectiveProfile = ownProfile || inheritedProfile + if (!effectiveProfile) continue + if (!ownProfile && !inventoryCountByCategoryId.get(Number(category.id))) continue + + const config = normalizeConsignmentProfileConfig(effectiveProfile.config_json) + items.push({ + categoryId: Number(category.id), + categoryKey: category.category_key, + name: category.name, + parentId: category.parent_id ? Number(category.parent_id) : null, + parentName: parentCategory?.name || '', + tabTitle: effectiveProfile.tab_title || config.tabTitle || effectiveProfile.name, + profileId: Number(effectiveProfile.id), + versionNo: Number(effectiveProfile.current_version_no || 1), + source: ownProfile ? 'own' : 'inherited', + filters: buildConsignmentInventoryFilterFields(config), + }) + } + + return { items: sortConsignmentInventoryFilterCategories(items) } +} + /** * 工单分类解析寄卖配置:优先二级分类自身,没有则回退一级分类。 * 只认已发布版本,返回可直接固化的快照。 diff --git a/apps/backend/src/services/worker-platform/consignment-spec-service.ts b/apps/backend/src/services/worker-platform/consignment-spec-service.ts index e31642f7..04d93757 100644 --- a/apps/backend/src/services/worker-platform/consignment-spec-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-spec-service.ts @@ -40,6 +40,11 @@ export type ConsignmentSpecField = { matchMode: ConsignmentSpecMatchMode wildcardValues: string[] showInItemName: boolean + /** + * 是否支持在后台「寄售仓库」作为筛选项。 + * 选项类字段(select / radio)默认开启,其余类型默认关闭,由运营在寄卖配置里显式打开。 + */ + filterable: boolean sortOrder: number } @@ -380,6 +385,12 @@ function normalizeSpecField(value: unknown, index: number): ConsignmentSpecField matchMode, wildcardValues: fallbackWildcards, showInItemName: bool(source.showInItemName ?? source.show_in_item_name, true), + // 缺省时选项类字段可直接筛选(苏尔南等存量配置无需重新发布即可用); + // 文本/数字/日期需要运营显式打开,避免筛选栏被自由输入项淹没。 + filterable: bool( + source.filterable ?? source.canFilter ?? source.can_filter, + type === 'select' || type === 'radio', + ), sortOrder: integer(source.sortOrder ?? source.sort_order, (index + 1) * 10, 0, 100_000), } } diff --git a/apps/frontend/src/components/admin/ConsignmentPendingMatchModal.tsx b/apps/frontend/src/components/admin/ConsignmentPendingMatchModal.tsx index 713d918a..e8f44a26 100644 --- a/apps/frontend/src/components/admin/ConsignmentPendingMatchModal.tsx +++ b/apps/frontend/src/components/admin/ConsignmentPendingMatchModal.tsx @@ -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 ( + {categoryName ? 分类:{categoryName} : null} 待抢单 {data?.pendingOrderCount ?? 0} 单 在库待匹配 {data?.inStockInventoryCount ?? 0} 件 匹配组合 {data?.pairCount ?? 0} 条 @@ -267,8 +283,8 @@ export default function ConsignmentPendingMatchModal({ ), diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx index 91037864..489257b2 100644 --- a/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx @@ -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() { } /> +
+ 支持筛选 + updateSpec(index, { filterable: checked })} + /> +
))} diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx index 202b81d5..e9760e96 100644 --- a/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx @@ -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 = { 小明: 'blue', 小张: 'green', @@ -147,12 +121,10 @@ function resolveGiftEvidenceEmptyText(record: { export default function ConsignmentInventoryPanel() { const { message } = App.useApp() - // 筛选条件 + // 筛选条件:分类与规格项全部来自「寄卖配置」已发布版本 const [status, setStatus] = useState('in_stock') - const [system, setSystem] = useState('') - const [serverZone, setServerZone] = useState('') - const [series, setSeries] = useState('') - const [diamondLevel, setDiamondLevel] = useState(0) + const [categoryId, setCategoryId] = useState(null) + const [specFilters, setSpecFilters] = useState>({}) const [allowMatching, setAllowMatching] = useState('') const [inboundRange, setInboundRange] = useState<[string, string] | null>(null) const [keyword, setKeyword] = useState('') @@ -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 = {} + 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() { ), }, + { + title: '寄卖分类', + key: 'category', + width: 140, + render: (_, record) => ( + + + {record.categoryName || '未分类'} + + {record.profileId ? ( + + 寄卖配置 #{record.profileId} + + ) : null} + + ), + }, { title: '和平精英规格', key: 'specs', @@ -813,43 +862,65 @@ export default function ConsignmentInventoryPanel() { 全部 + {/* 寄卖分类:选项与规格筛选项全部来自该类别的已发布寄卖配置 */} { - setServerZone(v) - setPage(1) - }} - /> - { - 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 ( + { + // 点小叉清空时立即生效;输入内容则在回车/失焦后提交,避免逐字请求 + if (!event.target.value) updateSpecFilter(field, '') + }} + onBlur={(event) => updateSpecFilter(field, event.target.value.trim())} + onPressEnter={(event) => + updateSpecFilter(field, (event.target as HTMLInputElement).value.trim()) + } + /> + ) + })} +