Files
order_site/apps/frontend/src/pages/admin/cost-center/ChannelMappingPanel.tsx
T
yml2213 601c10d2a5 refactor(cost-center-ui): 拆分渠道映射面板
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 通过。
2026-10-25 12:15:37 +08:00

428 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 渠道映射 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)}
/>
</>
)
}