diff --git a/apps/backend/src/routes/admin/cost-center.ts b/apps/backend/src/routes/admin/cost-center.ts index aec509b2..9c7f176d 100644 --- a/apps/backend/src/routes/admin/cost-center.ts +++ b/apps/backend/src/routes/admin/cost-center.ts @@ -27,8 +27,10 @@ import { createAdminProduct, deleteAdminChannelMapping, deleteAdminProduct, + getAdminChannelMappingDiagnostics, listAdminChannelMappings, listAdminProducts, + listAdminUnmappedMappingGroups, updateAdminChannelMapping, updateAdminProduct, } from '../../services/cost-center/product-catalog-service.js' @@ -273,6 +275,26 @@ router.get( }), ) +router.get( + '/cost-center/mappings/diagnostics', + requireAdminPermission('finance.view'), + createJsonHandler((req) => getAdminChannelMappingDiagnostics(req.query as JsonObject), { + successMessage: 'ok', + errorMessage: '读取渠道映射诊断失败', + scope: '[admin/cost-center/mappings/diagnostics]', + }), +) + +router.get( + '/cost-center/mappings/unmapped', + requireAdminPermission('finance.view'), + createJsonHandler((req) => listAdminUnmappedMappingGroups(req.query as JsonObject), { + successMessage: 'ok', + errorMessage: '读取未归一队列失败', + scope: '[admin/cost-center/mappings/unmapped]', + }), +) + router.post( '/cost-center/mappings', requireAdminPermission('finance.config'), diff --git a/apps/backend/src/services/cost-center/product-catalog-service.ts b/apps/backend/src/services/cost-center/product-catalog-service.ts index dea5959f..6691aa1f 100644 --- a/apps/backend/src/services/cost-center/product-catalog-service.ts +++ b/apps/backend/src/services/cost-center/product-catalog-service.ts @@ -15,6 +15,7 @@ import { normalizeMatchText, normalizeSkuSeriesEquivalence } from '../product-ma import { resolveSkuNameQuantity } from '../product-match/pricing.js' import { AFFILIATE_TASK_AGG_JOIN, + FULFILLMENT_MODES, MAPPING_CONFIDENCES, MAPPING_SOURCES, PRODUCT_CATEGORY_JOINS, @@ -500,6 +501,227 @@ export async function listAdminChannelMappings(payload: JsonObject = {}) { } } +/** + * 渠道映射诊断:商品/映射数量 + 未归一订单项与 SKU 组数。 + * + * 未归一数据直接读成本异常队列(145k 行),不去扫 316k 订单项。 + * 按履约方式分组统计:带模式过滤时走单次聚合(约 80ms), + * 「全部」时并行跑四种模式再求和,避免一次全表 hash join。 + */ +export async function getAdminChannelMappingDiagnostics(payload: JsonObject = {}) { + const source = text(payload.carrierType ?? payload.carrier_type ?? payload.source) + if (source) assertOneOf(source, PRODUCT_SOURCE_FILTERS, '商品来源无效') + const fulfillmentMode = text(payload.fulfillmentMode ?? payload.fulfillment_mode) + if (fulfillmentMode) assertOneOf(fulfillmentMode, FULFILLMENT_MODES, '履约方式无效') + + const productParams: unknown[] = [] + let sourceSql = 'TRUE' + if (source) { + productParams.push(source) + sourceSql = productSourceFilterSql(productParams.length) + } + const counts = await query<{ + product_total: number + mapping_total: number + mapping_product_total: number + }>( + ` + SELECT + ( + SELECT COUNT(*)::int FROM product_catalog p + WHERE p.status = 'active' AND ${sourceSql} + ) AS product_total, + ( + SELECT COUNT(*)::int + FROM channel_product_mappings m + JOIN product_catalog p ON p.id = m.canonical_product_id + WHERE m.enabled = TRUE AND ${sourceSql} + ) AS mapping_total, + ( + SELECT COUNT(DISTINCT m.canonical_product_id)::int + FROM channel_product_mappings m + JOIN product_catalog p ON p.id = m.canonical_product_id + WHERE m.enabled = TRUE AND ${sourceSql} + ) AS mapping_product_total + `, + productParams, + ) + + async function aggregateUnmapped(mode: string) { + const result = await query<{ group_total: number; item_total: number }>( + ` + WITH grouped AS ( + SELECT + ce.detail_json->>'provider' AS provider, + ce.detail_json->>'shopId' AS shop_id, + COALESCE(ce.detail_json->>'skuCode', '') AS sku_code, + oi.fulfillment_mode AS fulfillment_mode, + COUNT(*)::int AS item_count + FROM cost_exceptions ce + JOIN order_items oi ON oi.id = ce.order_item_id + WHERE ce.reason = 'product_mapping_missing' + AND ce.status = 'open' + AND oi.fulfillment_mode = $1 + GROUP BY 1, 2, 3, 4 + ) + SELECT + COUNT(*)::int AS group_total, + COALESCE(SUM(item_count), 0)::int AS item_total + FROM grouped + `, + [mode], + ) + return { + groupTotal: toNumber(result.rows[0]?.group_total), + itemTotal: toNumber(result.rows[0]?.item_total), + } + } + + const modes = fulfillmentMode + ? [fulfillmentMode] + : ['work_order', 'affiliate_dash', 'legacy_platform', 'manual'] + // 顺序跑:并行会同时扫 cost_exceptions,实测 4 路并发比串行更慢(I/O 争抢)。 + const aggregates: Array<{ groupTotal: number; itemTotal: number }> = [] + for (const mode of modes) { + aggregates.push(await aggregateUnmapped(mode)) + } + const unmappedGroupTotal = aggregates.reduce((sum, item) => sum + item.groupTotal, 0) + const unmappedItemTotal = aggregates.reduce((sum, item) => sum + item.itemTotal, 0) + + return { + fulfillmentMode: fulfillmentMode || null, + productTotal: toNumber(counts.rows[0]?.product_total), + mappingTotal: toNumber(counts.rows[0]?.mapping_total), + mappedProductTotal: toNumber(counts.rows[0]?.mapping_product_total), + unmappedGroupTotal, + unmappedItemTotal, + } +} + +/** + * 未归一订单项队列:按「来源 + 店铺 + 平台 SKU + 履约方式」聚合。 + * + * 每行代表一个可以直接建映射的平台 SKU,而不是 145k 条订单项流水。 + */ +export async function listAdminUnmappedMappingGroups(payload: JsonObject = {}) { + const page = normalizePage(payload.page) + const pageSize = normalizePageSize(payload.pageSize) + const offset = (page - 1) * pageSize + const params: unknown[] = [] + const clauses = [`ce.reason = 'product_mapping_missing'`, `ce.status = 'open'`] + + const fulfillmentMode = text(payload.fulfillmentMode ?? payload.fulfillment_mode) + if (fulfillmentMode) assertOneOf(fulfillmentMode, FULFILLMENT_MODES, '履约方式无效') + const provider = text(payload.provider) + if (provider) { + params.push(normalizeProviderKey(provider)) + clauses.push(`ce.detail_json->>'provider' = $${params.length}`) + } + const keyword = text(payload.keyword) + if (keyword) { + params.push(`%${keyword}%`) + clauses.push( + `(ce.detail_json->>'skuCode' ILIKE $${params.length} + OR ce.detail_json->>'skuName' ILIKE $${params.length} + OR ce.detail_json->>'shopId' ILIKE $${params.length})`, + ) + } + + // 分组总数单独算:SQL 守卫禁止窗口函数分页(会在 LIMIT 前消费全部候选行)。 + // 按履约方式拆开统计:命中索引,单模式约 90ms;「全部」时顺序跑四种模式。 + const modes = fulfillmentMode + ? [fulfillmentMode] + : ['work_order', 'affiliate_dash', 'legacy_platform', 'manual'] + const totals: Array<{ groupTotal: number; itemTotal: number }> = [] + for (const mode of modes) { + const totalParams = [...params, mode] + const result = await query<{ group_total: number; item_total: number }>( + ` + WITH grouped AS ( + SELECT + ce.detail_json->>'provider' AS provider, + ce.detail_json->>'shopId' AS shop_id, + COALESCE(ce.detail_json->>'skuCode', '') AS sku_code, + COUNT(*)::int AS item_count + FROM cost_exceptions ce + JOIN order_items oi ON oi.id = ce.order_item_id + WHERE ${clauses.join(' AND ')} + AND oi.fulfillment_mode = $${totalParams.length} + GROUP BY 1, 2, 3 + ) + SELECT + COUNT(*)::int AS group_total, + COALESCE(SUM(item_count), 0)::int AS item_total + FROM grouped + `, + totalParams, + ) + totals.push({ + groupTotal: toNumber(result.rows[0]?.group_total), + itemTotal: toNumber(result.rows[0]?.item_total), + }) + } + const groupTotal = totals.reduce((sum, item) => sum + item.groupTotal, 0) + const itemTotal = totals.reduce((sum, item) => sum + item.itemTotal, 0) + if (groupTotal === 0) { + return { + items: [], + pagination: { page, pageSize, total: 0, itemTotal: 0 }, + } + } + + const modeIndex = fulfillmentMode ? params.length + 1 : null + const pageIndex = params.length + (modeIndex ? 2 : 1) + const offsetIndex = pageIndex + 1 + const listParams = [...params, ...(fulfillmentMode ? [fulfillmentMode] : []), pageSize, offset] + const modeClause = modeIndex ? `AND oi.fulfillment_mode = $${modeIndex}` : '' + const result = await query<{ + provider: string + shop_id: string + sku_code: string + sku_name: string + fulfillment_mode: string + item_count: number + last_seen_at: string | null + }>( + ` + WITH grouped AS ( + SELECT + ce.detail_json->>'provider' AS provider, + ce.detail_json->>'shopId' AS shop_id, + COALESCE(ce.detail_json->>'skuCode', '') AS sku_code, + oi.fulfillment_mode AS fulfillment_mode, + MAX(ce.detail_json->>'skuName') AS sku_name, + MAX(NULLIF(ce.detail_json->>'basisAt', '')) AS last_seen_at, + COUNT(*)::int AS item_count + FROM cost_exceptions ce + JOIN order_items oi ON oi.id = ce.order_item_id + WHERE ${clauses.join(' AND ')} + ${modeClause} + GROUP BY 1, 2, 3, 4 + ) + SELECT * + FROM grouped g + ORDER BY g.item_count DESC, g.provider ASC, g.shop_id ASC, g.sku_code ASC + LIMIT $${pageIndex} OFFSET $${offsetIndex} + `, + listParams, + ) + + return { + items: result.rows.map((row) => ({ + provider: row.provider || '', + shopId: row.shop_id || '', + skuCode: row.sku_code || '', + skuName: row.sku_name || '', + fulfillmentMode: row.fulfillment_mode || '', + itemCount: toNumber(row.item_count), + lastSeenAt: row.last_seen_at || null, + })), + pagination: { page, pageSize, total: groupTotal, itemTotal }, + } +} + export async function createAdminChannelMapping(payload: JsonObject, _session: AdminSession) { const provider = normalizeProviderKey(normalizeRequiredText(payload.provider, '来源', 64)) assertKnownProvider(provider) @@ -774,6 +996,26 @@ export async function backfillAdminProductMappings(payload: JsonObject = {}) { if (!all || itemsResult.rows.length < limit) break } + // 回填后把已经归一的「商品未归一」异常一起收口,避免映射目录改完了 + // 未归一队列还挂着一批已处理完的旧异常。 + let resolvedExceptionCount = 0 + if (updatedCount > 0) { + const resolved = await query( + ` + UPDATE cost_exceptions ce + SET status = 'resolved', resolved_at = NOW() + WHERE ce.status = 'open' + AND ce.subject_type = 'order_item' + AND ce.reason = 'product_mapping_missing' + AND EXISTS ( + SELECT 1 FROM order_items oi + WHERE oi.id = ce.subject_id AND oi.canonical_product_id IS NOT NULL + ) + `, + ) + resolvedExceptionCount = resolved.rowCount || 0 + } + const remainingResult = await query<{ total: number }>( ` SELECT COUNT(*)::int AS total @@ -786,6 +1028,7 @@ export async function backfillAdminProductMappings(payload: JsonObject = {}) { ) return { updatedCount, + resolvedExceptionCount, limit, batches, remainingCount: Number(remainingResult.rows[0]?.total || 0), diff --git a/apps/frontend/src/pages/admin/cost-center/ChannelMappingPanel.tsx b/apps/frontend/src/pages/admin/cost-center/ChannelMappingPanel.tsx index 05eed268..f640614f 100644 --- a/apps/frontend/src/pages/admin/cost-center/ChannelMappingPanel.tsx +++ b/apps/frontend/src/pages/admin/cost-center/ChannelMappingPanel.tsx @@ -1,26 +1,36 @@ /** * 渠道映射 Tab:来源 + 店铺 + 平台 SKU -> 标准商品。 * + * 采用「诊断优先」结构: + * 1. 顶部统计卡:标准商品 / 映射条数 / 未归一订单项 / 未归一 SKU 组; + * 2. 未归一队列:按来源 + 店铺 + 平台 SKU 聚合成可操作的行,行内直接建映射; + * 3. 映射目录:只保留 4 列(标准商品 / 分类 / 渠道覆盖 / 映射),匹配方式进展开行。 + * * 映射唯一键包含匹配方式与名称键,91 同店铺复用 sku_code 才能消歧。 */ -import { SearchOutlined, SyncOutlined } from '@ant-design/icons' +import { ReloadOutlined, SearchOutlined, SyncOutlined } from '@ant-design/icons' import { + Alert, App, Button, Card, + Col, Form, Input, InputNumber, Modal, + Row, Select, Space, + Statistic, Switch, + Tabs, Tag, Typography, } from 'antd' import PercentTable from '@/components/common/PercentTable' import type { TableColumnsType } from 'antd' -import { useQuery } from '@tanstack/react-query' +import { useQuery, useQueryClient } from '@tanstack/react-query' import { useState } from 'react' import { @@ -28,15 +38,23 @@ import { backfillAdminProductMappings, createAdminChannelProductMapping, deleteAdminChannelProductMapping, + fetchAdminChannelMappingDiagnostics, fetchAdminChannelProductMappings, + fetchAdminUnmappedMappingGroups, updateAdminChannelProductMapping, } from '@/services/admin' -import type { ChannelProductMapping, ChannelProductMappingGroup } from '@/types/admin' +import type { + ChannelProductMapping, + ChannelProductMappingGroup, + FulfillmentMode, + UnmappedMappingGroup, +} from '@/types/admin' import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminLocalTablePagination, buildAdminTablePagination, } from '@/utils/admin-pagination' +import { formatAdminDateTime } from '@/utils/admin-time' import { useCostCenter } from './CostCenterContext' import { @@ -46,19 +64,54 @@ import { type MappingFormValues, } from './shared' +const FULFILLMENT_MODE_LABELS: Record = { + work_order: '接单平台', + affiliate_dash: '自动发货', + legacy_platform: '历史平台', + manual: '人工客服', +} + +const FULFILLMENT_MODE_COLORS: Record = { + work_order: 'blue', + affiliate_dash: 'green', + legacy_platform: 'default', + manual: 'orange', +} + export function ChannelMappingPanel() { const { message, modal } = App.useApp() + const queryClient = useQueryClient() const { canConfig, productSourceFilter, options, refreshAll } = useCostCenter() const [mappingForm] = Form.useForm() + const [view, setView] = useState<'catalog' | 'queue'>('catalog') const [mappingKeyword, setMappingKeyword] = useState('') const [mappingProvider, setMappingProvider] = useState('') const [mappingPage, setMappingPage] = useState(1) const [mappingPageSize, setMappingPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) const [expandedMappingProductIds, setExpandedMappingProductIds] = useState([]) const [mappingDetailPageSize, setMappingDetailPageSize] = useState(10) + + const [queueKeyword, setQueueKeyword] = useState('') + const [queueProvider, setQueueProvider] = useState('') + const [queuePage, setQueuePage] = useState(1) + const [queuePageSize, setQueuePageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) + const [mappingModalOpen, setMappingModalOpen] = useState(false) const [editingMapping, setEditingMapping] = useState(null) + const [fromQueue, setFromQueue] = useState(false) + + const providers = options?.providers || [] + + const diagnosticsQuery = useQuery({ + queryKey: ['admin-cost-center-mapping-diagnostics', productSourceFilter], + queryFn: () => + fetchAdminChannelMappingDiagnostics({ + carrierType: productSourceFilter, + fulfillmentMode: productSourceFilter, + }), + }) + const diagnostics = diagnosticsQuery.data?.data const mappingsQuery = useQuery({ queryKey: [ @@ -77,12 +130,37 @@ export function ChannelMappingPanel() { page: mappingPage, pageSize: mappingPageSize, }), + enabled: view === 'catalog', + }) + + const queueQuery = useQuery({ + queryKey: [ + 'admin-cost-center-unmapped-mappings', + productSourceFilter, + queueKeyword, + queueProvider, + queuePage, + queuePageSize, + ], + queryFn: () => + fetchAdminUnmappedMappingGroups({ + fulfillmentMode: productSourceFilter, + provider: queueProvider || undefined, + keyword: queueKeyword || undefined, + page: queuePage, + pageSize: queuePageSize, + }), + enabled: view === 'queue', }) const mappingGroups = mappingsQuery.data?.data.groups || [] const mappingTotal = mappingsQuery.data?.data.pagination.mappingTotal || 0 const mappingGroupTotal = mappingsQuery.data?.data.pagination.total || 0 - const providers = options?.providers || [] + + const unmappedGroups = queueQuery.data?.data.items || [] + const unmappedGroupTotal = queueQuery.data?.data.pagination.total || 0 + const unmappedItemTotal = queueQuery.data?.data.pagination.itemTotal || 0 + const productOptions = options?.products .filter((item) => !productSourceFilter || item.source === productSourceFilter) @@ -91,11 +169,26 @@ export function ChannelMappingPanel() { label: `${item.canonicalName}(${item.canonicalSku})`, })) || [] - function openCreateMapping() { + function invalidateMappingData() { + void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-mappings'] }) + void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-mapping-diagnostics'] }) + void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-unmapped-mappings'] }) + } + + function openCreateMapping(prefill?: { + provider?: string + shopId?: string + platformSkuCode?: string + platformSkuName?: string + }) { setEditingMapping(null) + setFromQueue(Boolean(prefill)) mappingForm.resetFields() mappingForm.setFieldsValue({ - provider: providers[0] || '91kaquan', + provider: prefill?.provider || mappingProvider || queueProvider || providers[0] || '91kaquan', + shopId: prefill?.shopId || '', + platformSkuCode: prefill?.platformSkuCode || '', + platformSkuName: prefill?.platformSkuName || '', matchType: 'sku_code', quantityMultiplier: 1, confidence: 'manual', @@ -106,6 +199,7 @@ export function ChannelMappingPanel() { } function openEditMapping(item: ChannelProductMapping) { + setFromQueue(false) setEditingMapping(item) mappingForm.setFieldsValue({ provider: item.provider, @@ -128,12 +222,27 @@ export function ChannelMappingPanel() { if (editingMapping) { await updateAdminChannelProductMapping(editingMapping.id, values) message.success('渠道映射已更新') - } else { - await createAdminChannelProductMapping(values) - message.success('渠道映射已创建') + setMappingModalOpen(false) + refreshAll() + invalidateMappingData() + return } + await createAdminChannelProductMapping(values) setMappingModalOpen(false) refreshAll() + invalidateMappingData() + if (fromQueue) { + modal.confirm({ + title: '映射已创建', + content: + '要把历史订单项也归一到这个标准商品吗?回填会按全部渠道映射重扫未归一订单项,并把已归一的异常自动收口。', + okText: '立即回填', + cancelText: '稍后', + onOk: () => runBackfill(), + }) + } else { + message.success('渠道映射已创建') + } } catch (error) { message.error(error instanceof Error ? error.message : '保存渠道映射失败') } @@ -151,6 +260,7 @@ export function ChannelMappingPanel() { await deleteAdminChannelProductMapping(item.id) message.success('渠道映射已删除') refreshAll() + invalidateMappingData() } catch (error) { message.error(error instanceof Error ? error.message : '删除渠道映射失败') } @@ -158,25 +268,30 @@ export function ChannelMappingPanel() { }) } + async function runBackfill() { + try { + const response = await backfillAdminProductMappings({ limit: 20000, all: true }) + const { updatedCount, resolvedExceptionCount, batches, remainingCount } = response.data + message.success( + `已回填 ${updatedCount} 条订单项映射(${batches} 批)${ + resolvedExceptionCount > 0 ? `,收口 ${resolvedExceptionCount} 条异常` : '' + }${remainingCount > 0 ? `,剩余 ${remainingCount} 条无可用映射` : ''}`, + ) + refreshAll() + invalidateMappingData() + } catch (error) { + message.error(error instanceof Error ? error.message : '回填商品映射失败') + } + } + function backfillMappings() { modal.confirm({ title: '回填订单项商品映射', content: - '按编码、编码 + 名称系列、编码 + 精确名称、名称系列兜底的优先级,分批匹配全部未映射订单项。', + '按编码、编码 + 名称系列、编码 + 精确名称、名称系列兜底的优先级,分批匹配全部未映射订单项;已归一的异常会自动收口。', okText: '开始回填', cancelText: '取消', - onOk: async () => { - try { - const response = await backfillAdminProductMappings({ limit: 20000, all: true }) - const { updatedCount, batches, remainingCount } = response.data - message.success( - `已回填 ${updatedCount} 条订单项映射(${batches} 批)${remainingCount > 0 ? `,剩余 ${remainingCount} 条无可用映射` : ''}`, - ) - refreshAll() - } catch (error) { - message.error(error instanceof Error ? error.message : '回填商品映射失败') - } - }, + onOk: () => runBackfill(), }) } @@ -201,6 +316,87 @@ export function ChannelMappingPanel() { }) } + const queueColumns: TableColumnsType = [ + { + title: '来源 / 店铺', + width: 220, + render: (_, row) => ( + + {providerLabel(row.provider)} + + {row.shopId || '全局(未按店铺区分)'} + + + ), + }, + { + title: '平台 SKU', + width: 320, + render: (_, row) => ( + + + {row.skuCode || '(无编码)'} + + + {row.skuName || '-'} + + + ), + }, + { + title: '履约方式', + dataIndex: 'fulfillmentMode', + width: 120, + render: (value: string) => ( + + {FULFILLMENT_MODE_LABELS[value] || value || '未知'} + + ), + }, + { + title: '订单项', + dataIndex: 'itemCount', + width: 110, + render: (value: number) => ( + + {value} + 项 + + ), + }, + { + title: '最近订单时间', + dataIndex: 'lastSeenAt', + width: 180, + render: (value: string | null) => (value ? formatAdminDateTime(value) : '-'), + }, + { + title: '操作', + width: 120, + render: (_, row) => + canConfig ? ( + + ) : null, + }, + ] + const mappingDetailColumns: TableColumnsType = [ { title: '来源', dataIndex: 'provider', width: 130, render: providerLabel }, { @@ -212,7 +408,7 @@ export function ChannelMappingPanel() { { title: '平台 SKU', dataIndex: 'platformSkuCode', - width: 220, + width: 230, render: (value: string, row) => ( {value || row.platformSkuName || '-'} @@ -225,17 +421,16 @@ export function ChannelMappingPanel() { { title: '匹配方式', dataIndex: 'matchType', - width: 160, + width: 180, render: (value: ChannelProductMapping['matchType']) => ( {mappingMatchTypeLabel(value)} ), }, { title: '倍率', dataIndex: 'quantityMultiplier', width: 70 }, - { title: '置信度', dataIndex: 'confidence', width: 85 }, { title: '启用', dataIndex: 'enabled', - width: 75, + width: 80, render: (value: boolean) => ( {value ? '启用' : '停用'} ), @@ -243,7 +438,6 @@ export function ChannelMappingPanel() { { title: '操作', width: 140, - fixed: 'right', render: (_, row) => canConfig ? ( @@ -262,30 +456,31 @@ export function ChannelMappingPanel() { { title: '标准商品', dataIndex: 'canonicalName', - width: 260, + width: 340, render: (value: string, row) => ( - - + + {value} - - {row.canonicalSku} · {row.unitName} - + {row.status !== 'active' ? 已归档 : null} + + {row.canonicalSku} · {row.unitName} + ), }, { title: '分类', dataIndex: 'category', - width: 120, + width: 130, render: (value: string) => value || 未分类, }, { - title: '覆盖范围', - width: 220, + title: '渠道覆盖', + width: 260, render: (_, row) => { const rowProviders = Array.from(new Set(row.mappings.map((item) => item.provider))) const shops = new Set(row.mappings.map((item) => `${item.provider}:${item.shopId || ''}`)) @@ -293,56 +488,40 @@ export function ChannelMappingPanel() { return ( {rowProviders.map((provider) => ( - {providerLabel(provider)} + + {providerLabel(provider)} + ))} - {shops} 个店铺键 + {shops} 个店铺键 ) }, }, { title: '映射', - width: 150, + width: 180, render: (_, row) => { const enabledCount = row.mappings.filter((item) => item.enabled).length + const disabledCount = row.mappings.length - enabledCount return ( - - {row.mappings.length} 条 - 启用 {enabledCount} + + {row.mappings.length} 条 + {disabledCount > 0 ? ( + + 启用 {enabledCount} · 停用 {disabledCount} + + ) : ( + + 全部启用 + + )} ) }, }, { - title: '匹配方式', - width: 280, - render: (_, row) => { - const matchTypes = Array.from(new Set(row.mappings.map((item) => item.matchType))) - return ( - - {matchTypes.map((matchType) => ( - - {mappingMatchTypeLabel(matchType)} - - ))} - - ) - }, - }, - { - title: '状态', - dataIndex: 'status', - width: 80, - render: (value: string) => ( - - {value === 'active' ? '启用' : '归档'} - - ), - }, - { - title: '目录', - width: 90, - fixed: 'right', + title: '明细', + width: 110, render: (_, row) => { const key = String(row.canonicalProductId) const expanded = expandedMappingProductIds.includes(key) @@ -357,21 +536,25 @@ export function ChannelMappingPanel() { ) }} > - {expanded ? '收起' : '展开'} + {expanded ? '收起明细' : '查看明细'} ) }, }, ] + const queueEmpty = !queueQuery.isLoading && unmappedGroupTotal === 0 + const sourceLabel = productSourceFilter + ? (FULFILLMENT_MODE_LABELS[productSourceFilter] ?? productSourceFilter) + : '全部来源' + return ( <> - 渠道映射目录 - {mappingTotal} 条映射 - {mappingGroupTotal} 个标准商品 + 渠道映射 + {sourceLabel} } extra={ @@ -381,102 +564,243 @@ export function ChannelMappingPanel() { 回填订单项 - ) : null } > - - } - placeholder="标准商品 / 平台 SKU" - value={mappingKeyword} - onChange={(event) => { - setMappingKeyword(event.target.value) - setMappingPage(1) - setExpandedMappingProductIds([]) - }} - style={{ width: 260 }} - /> - } + placeholder="标准商品 / 平台 SKU" + value={mappingKeyword} + onChange={(event) => { + setMappingKeyword(event.target.value) + setMappingPage(1) + setExpandedMappingProductIds([]) + }} + style={{ width: 260 }} + /> + } + placeholder="平台 SKU / 商品名 / 店铺" + value={queueKeyword} + onChange={(event) => { + setQueueKeyword(event.target.value) + setQueuePage(1) + }} + style={{ width: 260 }} + /> +