ChannelMappingPanel 908 行 → 427 行外壳 + 3 个视图组件: - ChannelMappingCatalog.tsx 322 映射目录 Tab(标准商品行 + 展开明细列) - ChannelMappingQueue.tsx 218 未归一队列 Tab(含「按内部 SKU 归一」提示) - MappingFormModal.tsx 135 新建/编辑弹窗表单 拆分边界: - 查询、筛选/分页/展开状态、提交与回填动作仍由外壳持有,切 Tab 不丢条件; - 视图组件只接收数据与回调,不直接访问 queryClient / message / modal; - 履约方式文案与颜色、MappingPrefill 提到 shared.ts,避免「视图依赖视图」。 行为保持: - JSX 与中文文案逐字保留,字面量集合比对只剩两类预期差异 (4 个履约标签移到 shared、2 处模板字符串改用局部变量,输出一致); - 队列空态判定、人工单提示、刷新 loading、展开本页/收起全部等逻辑语义不变。 验证:typecheck / lint / 前端测试 / build 通过。
428 lines
16 KiB
TypeScript
428 lines
16 KiB
TypeScript
/**
|
||
* 渠道映射 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<MappingFormValues>()
|
||
|
||
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<string[]>([])
|
||
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<ChannelProductMapping | null>(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 (
|
||
<>
|
||
<Card
|
||
title={
|
||
<Space size={6} wrap>
|
||
<Typography.Text strong>渠道映射</Typography.Text>
|
||
<Tag color="blue">{sourceLabel}</Tag>
|
||
</Space>
|
||
}
|
||
extra={
|
||
canConfig ? (
|
||
<Space>
|
||
<Button icon={<SyncOutlined />} onClick={backfillMappings}>
|
||
回填订单项
|
||
</Button>
|
||
<Button onClick={backfillQuantities}>回填数量</Button>
|
||
<Button type="primary" onClick={() => openCreateMapping()}>
|
||
新建映射
|
||
</Button>
|
||
</Space>
|
||
) : null
|
||
}
|
||
>
|
||
<Row gutter={[12, 12]} style={{ marginBottom: 12 }}>
|
||
{[
|
||
['标准商品', diagnostics?.productTotal ?? 0],
|
||
['渠道映射', diagnostics?.mappingTotal ?? 0],
|
||
['未归一订单项', diagnostics?.unmappedItemTotal ?? 0],
|
||
['未归一 SKU 组', diagnostics?.unmappedGroupTotal ?? 0],
|
||
].map(([label, value]) => (
|
||
<Col xs={12} xl={6} key={label}>
|
||
<Card size="small">
|
||
<Statistic
|
||
title={label}
|
||
value={Number(value)}
|
||
valueStyle={
|
||
label === '未归一订单项' || label === '未归一 SKU 组'
|
||
? { color: Number(value) > 0 ? '#d4380d' : '#389e0d' }
|
||
: undefined
|
||
}
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
))}
|
||
</Row>
|
||
|
||
<Tabs
|
||
activeKey={view}
|
||
onChange={(key) => setView(key as 'catalog' | 'queue')}
|
||
items={[
|
||
{
|
||
key: 'catalog',
|
||
label: `映射目录 (${Number(diagnostics?.mappedProductTotal ?? mappingGroupTotal)})`,
|
||
children: (
|
||
<ChannelMappingCatalog
|
||
groups={mappingGroups}
|
||
loading={mappingsQuery.isLoading}
|
||
page={mappingPage}
|
||
pageSize={mappingPageSize}
|
||
groupTotal={mappingGroupTotal}
|
||
mappingTotal={mappingTotal}
|
||
keyword={mappingKeyword}
|
||
provider={mappingProvider}
|
||
providers={providers}
|
||
canConfig={canConfig}
|
||
diagnostics={diagnostics}
|
||
sourceLabel={sourceLabel}
|
||
expandedRowKeys={expandedMappingProductIds}
|
||
detailPageSize={mappingDetailPageSize}
|
||
onKeywordChange={(value) => {
|
||
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: (
|
||
<ChannelMappingQueue
|
||
groups={unmappedGroups}
|
||
loading={queueQuery.isLoading}
|
||
fetching={queueQuery.isFetching || diagnosticsQuery.isFetching}
|
||
page={queuePage}
|
||
pageSize={queuePageSize}
|
||
groupTotal={unmappedGroupTotal}
|
||
itemTotal={unmappedItemTotal}
|
||
keyword={queueKeyword}
|
||
provider={queueProvider}
|
||
providers={providers}
|
||
canConfig={canConfig}
|
||
sourceLabel={sourceLabel}
|
||
onKeywordChange={(value) => {
|
||
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}
|
||
/>
|
||
),
|
||
},
|
||
]}
|
||
/>
|
||
</Card>
|
||
|
||
<MappingFormModal
|
||
open={mappingModalOpen}
|
||
editing={editingMapping}
|
||
fromQueue={fromQueue}
|
||
form={mappingForm}
|
||
providers={providers}
|
||
productOptions={productOptions}
|
||
onSubmit={submitMapping}
|
||
onCancel={() => setMappingModalOpen(false)}
|
||
/>
|
||
</>
|
||
)
|
||
}
|