/** * 渠道映射 Tab:来源 + 店铺 + 平台 SKU -> 标准商品。 * * 采用「诊断优先」结构: * 1. 顶部统计卡:标准商品 / 映射条数 / 未归一订单项 / 未归一 SKU 组; * 2. 未归一队列:按来源 + 店铺 + 平台 SKU 聚合成可操作的行,行内直接建映射; * 3. 映射目录:只保留 4 列(标准商品 / 分类 / 渠道覆盖 / 映射),匹配方式进展开行。 * * 2026-10-25 拆分:本文件只保留查询、状态与动作,视图拆到 * - ChannelMappingCatalog.tsx 映射目录 Tab * - ChannelMappingQueue.tsx 未归一队列 Tab * - MappingFormModal.tsx 新建/编辑弹窗 * 筛选与分页状态仍由本文件持有,切换 Tab 不会丢条件。 */ import { SyncOutlined } from '@ant-design/icons' import { App, Button, Card, Col, Form, Row, Space, Statistic, Tabs, Tag, Typography } from 'antd' import { useQuery, useQueryClient } from '@tanstack/react-query' import { useState } from 'react' import { backfillAdminNormalizedQuantities, backfillAdminProductMappings, createAdminChannelProductMapping, deleteAdminChannelProductMapping, fetchAdminChannelMappingDiagnostics, fetchAdminChannelProductMappings, fetchAdminUnmappedMappingGroups, updateAdminChannelProductMapping, } from '@/services/admin' import type { ChannelProductMapping } from '@/types/admin' import { ADMIN_DEFAULT_PAGE_SIZE } from '@/utils/admin-pagination' import ChannelMappingCatalog from './ChannelMappingCatalog' import ChannelMappingQueue from './ChannelMappingQueue' import MappingFormModal from './MappingFormModal' import { useCostCenter } from './CostCenterContext' import { FULFILLMENT_MODE_LABELS, providerLabel, type MappingFormValues, type MappingPrefill, } from './shared' 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: [ 'admin-cost-center-mappings', productSourceFilter, mappingKeyword, mappingProvider, mappingPage, mappingPageSize, ], queryFn: () => fetchAdminChannelProductMappings({ keyword: mappingKeyword || undefined, provider: mappingProvider || undefined, carrierType: productSourceFilter, 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 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) .map((item) => ({ value: item.id, label: `${item.canonicalName}(${item.canonicalSku})`, })) || [] 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?: MappingPrefill) { setEditingMapping(null) setFromQueue(Boolean(prefill)) mappingForm.resetFields() mappingForm.setFieldsValue({ provider: prefill?.provider || mappingProvider || queueProvider || providers[0] || '91kaquan', shopId: prefill?.shopId || '', platformSkuCode: prefill?.platformSkuCode || '', platformSkuName: prefill?.platformSkuName || '', matchType: 'sku_code', quantityMultiplier: 1, confidence: 'manual', source: 'manual', enabled: true, }) setMappingModalOpen(true) } function openEditMapping(item: ChannelProductMapping) { setFromQueue(false) setEditingMapping(item) mappingForm.setFieldsValue({ provider: item.provider, shopId: item.shopId, platformSkuCode: item.platformSkuCode, platformSkuName: item.platformSkuName, matchType: item.matchType, canonicalProductId: item.canonicalProductId, quantityMultiplier: item.quantityMultiplier, confidence: item.confidence, source: item.source, enabled: item.enabled, note: item.note, }) setMappingModalOpen(true) } async function submitMapping(values: MappingFormValues) { try { if (editingMapping) { await updateAdminChannelProductMapping(editingMapping.id, 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 : '保存渠道映射失败') } } function deleteMapping(item: ChannelProductMapping) { modal.confirm({ title: '删除渠道映射', content: `确认删除「${providerLabel(item.provider)} / ${item.shopId || '全局'} / ${item.platformSkuCode || item.platformSkuName || '-'}」这条映射?删除后该平台 SKU 将不再自动归一。`, okText: '删除', okButtonProps: { danger: true }, cancelText: '取消', onOk: async () => { try { await deleteAdminChannelProductMapping(item.id) message.success('渠道映射已删除') refreshAll() invalidateMappingData() } catch (error) { message.error(error instanceof Error ? error.message : '删除渠道映射失败') } }, }) } 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: () => runBackfill(), }) } function backfillQuantities() { modal.confirm({ title: '回填归一数量', content: '按 SKU 名称/编码解析数量,自动分批处理全部订单项;解析不到时按 1 件记录。', okText: '开始回填', cancelText: '取消', onOk: async () => { try { const response = await backfillAdminNormalizedQuantities({ limit: 20000, all: true }) const { updatedCount, remainingCount, batches } = response.data message.success( `已回填 ${updatedCount} 条归一数量(${batches} 批)${remainingCount > 0 ? `,剩余 ${remainingCount} 条` : ''}`, ) refreshAll() } catch (error) { message.error(error instanceof Error ? error.message : '回填归一数量失败') } }, }) } const sourceLabel = productSourceFilter ? (FULFILLMENT_MODE_LABELS[productSourceFilter] ?? productSourceFilter) : '全部来源' return ( <> 渠道映射 {sourceLabel} } extra={ canConfig ? ( ) : null } > {[ ['标准商品', diagnostics?.productTotal ?? 0], ['渠道映射', diagnostics?.mappingTotal ?? 0], ['未归一订单项', diagnostics?.unmappedItemTotal ?? 0], ['未归一 SKU 组', diagnostics?.unmappedGroupTotal ?? 0], ].map(([label, value]) => ( 0 ? '#d4380d' : '#389e0d' } : undefined } /> ))} setView(key as 'catalog' | 'queue')} items={[ { key: 'catalog', label: `映射目录 (${Number(diagnostics?.mappedProductTotal ?? mappingGroupTotal)})`, children: ( { setMappingKeyword(value) setMappingPage(1) setExpandedMappingProductIds([]) }} onProviderChange={(value) => { setMappingProvider(value) setMappingPage(1) setExpandedMappingProductIds([]) }} onPageChange={(page, pageSize) => { setMappingPage(page) setMappingPageSize(pageSize) setExpandedMappingProductIds([]) }} onExpandedRowKeysChange={setExpandedMappingProductIds} onDetailPageSizeChange={setMappingDetailPageSize} onGoQueue={() => setView('queue')} onCreateMapping={openCreateMapping} onEditMapping={openEditMapping} onDeleteMapping={deleteMapping} /> ), }, { key: 'queue', label: `未归一 (${Number(diagnostics?.unmappedGroupTotal || 0)})`, children: ( { setQueueKeyword(value) setQueuePage(1) }} onProviderChange={(value) => { setQueueProvider(value) setQueuePage(1) }} onPageChange={(page, pageSize) => { setQueuePage(page) setQueuePageSize(pageSize) }} onRefresh={() => { void queueQuery.refetch() void diagnosticsQuery.refetch() }} onCreateMapping={openCreateMapping} /> ), }, ]} /> setMappingModalOpen(false)} /> ) }