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 通过。
This commit is contained in:
yml2213
2026-10-25 12:15:37 +08:00
parent d90c066888
commit 601c10d2a5
5 changed files with 789 additions and 572 deletions
@@ -0,0 +1,322 @@
/**
* 渠道映射 · 映射目录 Tab。
*
* 行是「标准商品」,展开后是该商品下的渠道映射明细。
* 筛选、分页、展开状态都由上层持有,这里只负责渲染与回调。
*/
import { SearchOutlined } from '@ant-design/icons'
import { Alert, Button, Input, Select, Space, Tag, Typography } from 'antd'
import type { TableColumnsType } from 'antd'
import PercentTable from '@/components/common/PercentTable'
import type { ChannelProductMapping, ChannelProductMappingGroup } from '@/types/admin'
import { buildAdminLocalTablePagination, buildAdminTablePagination } from '@/utils/admin-pagination'
import { mappingMatchTypeLabel, providerLabel, type MappingPrefill } from './shared'
export type MappingDiagnostics = {
productTotal?: number
mappingTotal?: number
mappedProductTotal?: number
unmappedItemTotal?: number
unmappedGroupTotal?: number
}
type Props = {
groups: ChannelProductMappingGroup[]
loading: boolean
page: number
pageSize: number
groupTotal: number
mappingTotal: number
keyword: string
provider: string
providers: string[]
canConfig: boolean
diagnostics?: MappingDiagnostics
sourceLabel: string
expandedRowKeys: string[]
detailPageSize: number
onKeywordChange: (value: string) => void
onProviderChange: (value: string) => void
onPageChange: (page: number, pageSize: number) => void
onExpandedRowKeysChange: (keys: string[]) => void
onDetailPageSizeChange: (size: number) => void
onGoQueue: () => void
onCreateMapping: (prefill?: MappingPrefill) => void
onEditMapping: (row: ChannelProductMapping) => void
onDeleteMapping: (row: ChannelProductMapping) => void
}
export default function ChannelMappingCatalog({
groups,
loading,
page,
pageSize,
groupTotal,
mappingTotal,
keyword,
provider,
providers,
canConfig,
diagnostics,
sourceLabel,
expandedRowKeys,
detailPageSize,
onKeywordChange,
onProviderChange,
onPageChange,
onExpandedRowKeysChange,
onDetailPageSizeChange,
onGoQueue,
onCreateMapping,
onEditMapping,
onDeleteMapping,
}: Props) {
const unmappedGroupTotal = Number(diagnostics?.unmappedGroupTotal || 0)
const unmappedItemTotal = Number(diagnostics?.unmappedItemTotal || 0)
const detailColumns: TableColumnsType<ChannelProductMapping> = [
{ title: '来源', dataIndex: 'provider', width: 130, render: providerLabel },
{
title: '店铺',
dataIndex: 'shopId',
width: 150,
render: (value: string) => value || <Tag>全局</Tag>,
},
{
title: '平台 SKU',
dataIndex: 'platformSkuCode',
width: 230,
render: (value: string, row) => (
<Space direction="vertical" size={0}>
<Typography.Text strong>{value || row.platformSkuName || '-'}</Typography.Text>
<Typography.Text type="secondary">
{value ? row.platformSkuName || '-' : row.platformSkuNameKey || '-'}
</Typography.Text>
</Space>
),
},
{
title: '匹配方式',
dataIndex: 'matchType',
width: 180,
render: (value: ChannelProductMapping['matchType']) => (
<Tag color={value === 'sku_code' ? 'green' : 'blue'}>{mappingMatchTypeLabel(value)}</Tag>
),
},
{ title: '倍率', dataIndex: 'quantityMultiplier', width: 70 },
{
title: '启用',
dataIndex: 'enabled',
width: 80,
render: (value: boolean) => (
<Tag color={value ? 'green' : 'default'}>{value ? '启用' : '停用'}</Tag>
),
},
{
title: '操作',
width: 140,
render: (_, row) =>
canConfig ? (
<Space size={4}>
<Button size="small" onClick={() => onEditMapping(row)}>
编辑
</Button>
<Button size="small" danger onClick={() => onDeleteMapping(row)}>
删除
</Button>
</Space>
) : null,
},
]
const groupColumns: TableColumnsType<ChannelProductMappingGroup> = [
{
title: '标准商品',
dataIndex: 'canonicalName',
width: 340,
render: (value: string, row) => (
<Space direction="vertical" size={0} style={{ width: '100%' }}>
<Space size={6} wrap>
<Typography.Text strong ellipsis={{ tooltip: value }}>
{value}
</Typography.Text>
{row.status !== 'active' ? <Tag>已归档</Tag> : null}
</Space>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{row.canonicalSku} · {row.unitName}
</Typography.Text>
</Space>
),
},
{
title: '分类',
dataIndex: 'category',
width: 130,
render: (value: string) =>
value || <Typography.Text type="secondary">未分类</Typography.Text>,
},
{
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 || ''}`))
.size
return (
<Space size={[4, 4]} wrap>
{rowProviders.map((item) => (
<Tag key={item} color="blue">
{providerLabel(item)}
</Tag>
))}
<Tag>{shops} 个店铺键</Tag>
</Space>
)
},
},
{
title: '映射',
width: 180,
render: (_, row) => {
const enabledCount = row.mappings.filter((item) => item.enabled).length
const disabledCount = row.mappings.length - enabledCount
return (
<Space direction="vertical" size={0}>
<Typography.Text strong>{row.mappings.length} 条</Typography.Text>
{disabledCount > 0 ? (
<Typography.Text type="warning" style={{ fontSize: 12 }}>
启用 {enabledCount} · 停用 {disabledCount}
</Typography.Text>
) : (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
全部启用
</Typography.Text>
)}
</Space>
)
},
},
{
title: '明细',
width: 110,
render: (_, row) => {
const key = String(row.canonicalProductId)
const expanded = expandedRowKeys.includes(key)
return (
<Button
size="small"
type="link"
onClick={(event) => {
event.stopPropagation()
onExpandedRowKeysChange(
expanded
? expandedRowKeys.filter((item) => item !== key)
: [...expandedRowKeys, key],
)
}}
>
{expanded ? '收起明细' : '查看明细'}
</Button>
)
},
},
]
return (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{unmappedGroupTotal > 0 ? (
<Alert
type="warning"
showIcon
message={`还有 ${unmappedGroupTotal} 组平台 SKU 未归一(${unmappedItemTotal} 个订单项)`}
action={
<Button size="small" onClick={onGoQueue}>
去处理
</Button>
}
/>
) : (
<Alert
type="success"
showIcon
message={`${sourceLabel}的订单项已全部归一`}
description={`${Number(diagnostics?.productTotal || 0)} 个标准商品、${Number(diagnostics?.mappingTotal || 0)} 条映射已覆盖全部订单;新订单若命中不到映射,会在每日成本同步后出现在「未归一」队列。`}
/>
)}
<Space wrap>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="标准商品 / 平台 SKU"
value={keyword}
onChange={(event) => onKeywordChange(event.target.value)}
style={{ width: 260 }}
/>
<Select
allowClear
placeholder="来源"
value={provider || undefined}
onChange={(value) => onProviderChange(value || '')}
options={providers.map((item) => ({
value: item,
label: providerLabel(item),
}))}
style={{ width: 180 }}
/>
<Button
onClick={() =>
onExpandedRowKeysChange(groups.map((item) => String(item.canonicalProductId)))
}
>
展开本页
</Button>
<Button onClick={() => onExpandedRowKeysChange([])}>收起全部</Button>
</Space>
<PercentTable<ChannelProductMappingGroup>
tableLayout="fixed"
rowKey={(row) => String(row.canonicalProductId)}
size="small"
columns={groupColumns}
dataSource={groups}
loading={loading}
scroll={{ x: 1020 }}
expandable={{
expandedRowKeys,
onExpandedRowsChange: (keys) => onExpandedRowKeysChange(keys.map((key) => String(key))),
expandRowByClick: true,
expandedRowRender: (group) => (
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Typography.Text type="secondary">
该标准商品下共有 {group.mappings.length} 条渠道映射
</Typography.Text>
<PercentTable<ChannelProductMapping>
tableLayout="fixed"
rowKey="id"
size="small"
columns={detailColumns}
dataSource={group.mappings}
pagination={buildAdminLocalTablePagination({
pageSize: detailPageSize,
size: 'small',
onChange: (_page, nextPageSize) => onDetailPageSizeChange(nextPageSize),
})}
scroll={{ x: 1080 }}
/>
</Space>
),
}}
pagination={buildAdminTablePagination({
current: page,
pageSize,
total: groupTotal,
showTotal: (total) => `共 ${total} 个标准商品 / ${mappingTotal} 条映射`,
onChange: (nextPage, nextPageSize) => onPageChange(nextPage, nextPageSize),
})}
/>
</Space>
)
}
@@ -6,30 +6,14 @@
* 2. 未归一队列:按来源 + 店铺 + 平台 SKU 聚合成可操作的行,行内直接建映射;
* 3. 映射目录:只保留 4 列(标准商品 / 分类 / 渠道覆盖 / 映射),匹配方式进展开行。
*
* 映射唯一键包含匹配方式与名称键,91 同店铺复用 sku_code 才能消歧。
* 2026-10-25 拆分:本文件只保留查询、状态与动作,视图拆到
* - ChannelMappingCatalog.tsx 映射目录 Tab
* - ChannelMappingQueue.tsx 未归一队列 Tab
* - MappingFormModal.tsx 新建/编辑弹窗
* 筛选与分页状态仍由本文件持有,切换 Tab 不会丢条件。
*/
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 { 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'
@@ -43,41 +27,20 @@ import {
fetchAdminUnmappedMappingGroups,
updateAdminChannelProductMapping,
} from '@/services/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 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 {
MAPPING_MATCH_TYPE_OPTIONS,
mappingMatchTypeLabel,
FULFILLMENT_MODE_LABELS,
providerLabel,
type MappingFormValues,
type MappingPrefill,
} from './shared'
const FULFILLMENT_MODE_LABELS: Record<string, string> = {
work_order: '接单平台',
affiliate_dash: '自动发货',
legacy_platform: '历史平台',
manual: '人工客服',
}
const FULFILLMENT_MODE_COLORS: Record<string, string> = {
work_order: 'blue',
affiliate_dash: 'green',
legacy_platform: 'default',
manual: 'orange',
}
export function ChannelMappingPanel() {
const { message, modal } = App.useApp()
const queryClient = useQueryClient()
@@ -175,12 +138,7 @@ export function ChannelMappingPanel() {
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-unmapped-mappings'] })
}
function openCreateMapping(prefill?: {
provider?: string
shopId?: string
platformSkuCode?: string
platformSkuName?: string
}) {
function openCreateMapping(prefill?: MappingPrefill) {
setEditingMapping(null)
setFromQueue(Boolean(prefill))
mappingForm.resetFields()
@@ -316,234 +274,6 @@ export function ChannelMappingPanel() {
})
}
const queueColumns: TableColumnsType<UnmappedMappingGroup> = [
{
title: '来源 / 店铺',
width: 220,
render: (_, row) => (
<Space direction="vertical" size={0} style={{ width: '100%' }}>
<Typography.Text>{providerLabel(row.provider)}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{row.shopId || '全局(未按店铺区分)'}
</Typography.Text>
</Space>
),
},
{
title: '平台 SKU',
width: 320,
render: (_, row) => (
<Space direction="vertical" size={0} style={{ width: '100%' }}>
<Typography.Text strong ellipsis={{ tooltip: row.skuCode || row.skuName }}>
{row.skuCode || '(无编码)'}
</Typography.Text>
<Typography.Text
type="secondary"
style={{ fontSize: 12 }}
ellipsis={{ tooltip: row.skuName }}
>
{row.skuName || '-'}
</Typography.Text>
</Space>
),
},
{
title: '履约方式',
dataIndex: 'fulfillmentMode',
width: 120,
render: (value: string) => (
<Tag color={FULFILLMENT_MODE_COLORS[value] || 'default'}>
{FULFILLMENT_MODE_LABELS[value] || value || '未知'}
</Tag>
),
},
{
title: '订单项',
dataIndex: 'itemCount',
width: 110,
render: (value: number) => (
<Space size={4}>
<Typography.Text strong>{value}</Typography.Text>
<Typography.Text type="secondary">项</Typography.Text>
</Space>
),
},
{
title: '最近订单时间',
dataIndex: 'lastSeenAt',
width: 180,
render: (value: string | null) => (value ? formatAdminDateTime(value) : '-'),
},
{
title: '操作',
width: 120,
render: (_, row) =>
canConfig ? (
<Button
size="small"
type="primary"
onClick={() =>
openCreateMapping({
provider: row.provider,
shopId: row.shopId,
platformSkuCode: row.skuCode,
platformSkuName: row.skuName,
})
}
>
建映射
</Button>
) : null,
},
]
const mappingDetailColumns: TableColumnsType<ChannelProductMapping> = [
{ title: '来源', dataIndex: 'provider', width: 130, render: providerLabel },
{
title: '店铺',
dataIndex: 'shopId',
width: 150,
render: (value: string) => value || <Tag>全局</Tag>,
},
{
title: '平台 SKU',
dataIndex: 'platformSkuCode',
width: 230,
render: (value: string, row) => (
<Space direction="vertical" size={0}>
<Typography.Text strong>{value || row.platformSkuName || '-'}</Typography.Text>
<Typography.Text type="secondary">
{value ? row.platformSkuName || '-' : row.platformSkuNameKey || '-'}
</Typography.Text>
</Space>
),
},
{
title: '匹配方式',
dataIndex: 'matchType',
width: 180,
render: (value: ChannelProductMapping['matchType']) => (
<Tag color={value === 'sku_code' ? 'green' : 'blue'}>{mappingMatchTypeLabel(value)}</Tag>
),
},
{ title: '倍率', dataIndex: 'quantityMultiplier', width: 70 },
{
title: '启用',
dataIndex: 'enabled',
width: 80,
render: (value: boolean) => (
<Tag color={value ? 'green' : 'default'}>{value ? '启用' : '停用'}</Tag>
),
},
{
title: '操作',
width: 140,
render: (_, row) =>
canConfig ? (
<Space size={4}>
<Button size="small" onClick={() => openEditMapping(row)}>
编辑
</Button>
<Button size="small" danger onClick={() => deleteMapping(row)}>
删除
</Button>
</Space>
) : null,
},
]
const mappingGroupColumns: TableColumnsType<ChannelProductMappingGroup> = [
{
title: '标准商品',
dataIndex: 'canonicalName',
width: 340,
render: (value: string, row) => (
<Space direction="vertical" size={0} style={{ width: '100%' }}>
<Space size={6} wrap>
<Typography.Text strong ellipsis={{ tooltip: value }}>
{value}
</Typography.Text>
{row.status !== 'active' ? <Tag>已归档</Tag> : null}
</Space>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{row.canonicalSku} · {row.unitName}
</Typography.Text>
</Space>
),
},
{
title: '分类',
dataIndex: 'category',
width: 130,
render: (value: string) =>
value || <Typography.Text type="secondary">未分类</Typography.Text>,
},
{
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 || ''}`))
.size
return (
<Space size={[4, 4]} wrap>
{rowProviders.map((provider) => (
<Tag key={provider} color="blue">
{providerLabel(provider)}
</Tag>
))}
<Tag>{shops} 个店铺键</Tag>
</Space>
)
},
},
{
title: '映射',
width: 180,
render: (_, row) => {
const enabledCount = row.mappings.filter((item) => item.enabled).length
const disabledCount = row.mappings.length - enabledCount
return (
<Space direction="vertical" size={0}>
<Typography.Text strong>{row.mappings.length} 条</Typography.Text>
{disabledCount > 0 ? (
<Typography.Text type="warning" style={{ fontSize: 12 }}>
启用 {enabledCount} · 停用 {disabledCount}
</Typography.Text>
) : (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
全部启用
</Typography.Text>
)}
</Space>
)
},
},
{
title: '明细',
width: 110,
render: (_, row) => {
const key = String(row.canonicalProductId)
const expanded = expandedMappingProductIds.includes(key)
return (
<Button
size="small"
type="link"
onClick={(event) => {
event.stopPropagation()
setExpandedMappingProductIds((current) =>
current.includes(key) ? current.filter((item) => item !== key) : [...current, key],
)
}}
>
{expanded ? '收起明细' : '查看明细'}
</Button>
)
},
},
]
const queueEmpty = !queueQuery.isLoading && unmappedGroupTotal === 0
const sourceLabel = productSourceFilter
? (FULFILLMENT_MODE_LABELS[productSourceFilter] ?? productSourceFilter)
: '全部来源'
@@ -602,307 +332,96 @@ export function ChannelMappingPanel() {
key: 'catalog',
label: `映射目录 (${Number(diagnostics?.mappedProductTotal ?? mappingGroupTotal)})`,
children: (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{Number(diagnostics?.unmappedGroupTotal || 0) > 0 ? (
<Alert
type="warning"
showIcon
message={`还有 ${Number(diagnostics?.unmappedGroupTotal || 0)} 组平台 SKU 未归一(${Number(diagnostics?.unmappedItemTotal || 0)} 个订单项)`}
action={
<Button size="small" onClick={() => setView('queue')}>
去处理
</Button>
}
/>
) : (
<Alert
type="success"
showIcon
message={`${sourceLabel}的订单项已全部归一`}
description={`${Number(diagnostics?.productTotal || 0)} 个标准商品、${Number(diagnostics?.mappingTotal || 0)} 条映射已覆盖全部订单;新订单若命中不到映射,会在每日成本同步后出现在「未归一」队列。`}
/>
)}
<Space wrap>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="标准商品 / 平台 SKU"
value={mappingKeyword}
onChange={(event) => {
setMappingKeyword(event.target.value)
setMappingPage(1)
setExpandedMappingProductIds([])
}}
style={{ width: 260 }}
/>
<Select
allowClear
placeholder="来源"
value={mappingProvider || undefined}
onChange={(value) => {
setMappingProvider(value || '')
setMappingPage(1)
setExpandedMappingProductIds([])
}}
options={providers.map((provider) => ({
value: provider,
label: providerLabel(provider),
}))}
style={{ width: 180 }}
/>
<Button
onClick={() =>
setExpandedMappingProductIds(
mappingGroups.map((group) => String(group.canonicalProductId)),
)
}
>
展开本页
</Button>
<Button onClick={() => setExpandedMappingProductIds([])}>收起全部</Button>
</Space>
<PercentTable<ChannelProductMappingGroup>
tableLayout="fixed"
rowKey={(row) => String(row.canonicalProductId)}
size="small"
columns={mappingGroupColumns}
dataSource={mappingGroups}
loading={mappingsQuery.isLoading}
scroll={{ x: 1020 }}
expandable={{
expandedRowKeys: expandedMappingProductIds,
onExpandedRowsChange: (keys) =>
setExpandedMappingProductIds(keys.map((key) => String(key))),
expandRowByClick: true,
expandedRowRender: (group) => (
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Typography.Text type="secondary">
该标准商品下共有 {group.mappings.length} 条渠道映射
</Typography.Text>
<PercentTable<ChannelProductMapping>
tableLayout="fixed"
rowKey="id"
size="small"
columns={mappingDetailColumns}
dataSource={group.mappings}
pagination={buildAdminLocalTablePagination({
pageSize: mappingDetailPageSize,
size: 'small',
onChange: (_page, pageSize) => setMappingDetailPageSize(pageSize),
})}
scroll={{ x: 1080 }}
/>
</Space>
),
}}
pagination={buildAdminTablePagination({
current: mappingPage,
pageSize: mappingPageSize,
total: mappingGroupTotal,
showTotal: (total) => `共 ${total} 个标准商品 / ${mappingTotal} 条映射`,
onChange: (page, pageSize) => {
setMappingPage(page)
setMappingPageSize(pageSize)
setExpandedMappingProductIds([])
},
})}
/>
</Space>
<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: (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{queueEmpty ? (
<Alert
type="success"
showIcon
message={`${sourceLabel}没有未归一的平台 SKU`}
description="订单项全部命中了标准商品映射;这里为空即代表销售与成本都能按商品归集。"
/>
) : (
<Alert
type="info"
showIcon
message="每行是一个可以直接建映射的平台 SKU"
description="队列来自每日成本扫描;建好映射后点右上角「回填订单项」把历史订单归一,已归一的异常会自动收口。"
/>
)}
{unmappedGroups.some((row) => row.fulfillmentMode === 'manual') ? (
<Alert
type="warning"
showIcon
message="人工客服的未归一建议按内部 SKU 批量归一"
description="人工单的内部 SKU(快手 skuNick / 拼多多 outSkuSn)和平台 sku_code 不是同一个值,手建渠道映射通常抓不到历史单。请到「标准商品 → 归一人工商品」,按内部 SKU 生成 man: 商品并回填。"
/>
) : null}
<Space wrap>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="平台 SKU / 商品名 / 店铺"
value={queueKeyword}
onChange={(event) => {
setQueueKeyword(event.target.value)
setQueuePage(1)
}}
style={{ width: 260 }}
/>
<Select
allowClear
placeholder="来源"
value={queueProvider || undefined}
onChange={(value) => {
setQueueProvider(value || '')
setQueuePage(1)
}}
options={providers.map((provider) => ({
value: provider,
label: providerLabel(provider),
}))}
style={{ width: 180 }}
/>
<Button
icon={<ReloadOutlined />}
loading={queueQuery.isFetching || diagnosticsQuery.isFetching}
onClick={() => {
void queueQuery.refetch()
void diagnosticsQuery.refetch()
}}
>
刷新队列
</Button>
</Space>
<PercentTable<UnmappedMappingGroup>
tableLayout="fixed"
rowKey={(row) =>
`${row.provider}-${row.shopId}-${row.fulfillmentMode}-${row.skuCode}`
}
size="small"
columns={queueColumns}
dataSource={unmappedGroups}
loading={queueQuery.isLoading}
scroll={{ x: 1080 }}
pagination={buildAdminTablePagination({
current: queuePage,
pageSize: queuePageSize,
total: unmappedGroupTotal,
showTotal: (total) => `共 ${total} 组 / ${unmappedItemTotal} 个订单项`,
onChange: (page, pageSize) => {
setQueuePage(page)
setQueuePageSize(pageSize)
},
})}
/>
</Space>
<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>
<Modal
title={editingMapping ? '编辑渠道映射' : fromQueue ? '为平台 SKU 建映射' : '新建渠道映射'}
<MappingFormModal
open={mappingModalOpen}
editing={editingMapping}
fromQueue={fromQueue}
form={mappingForm}
providers={providers}
productOptions={productOptions}
onSubmit={submitMapping}
onCancel={() => setMappingModalOpen(false)}
onOk={() => mappingForm.submit()}
okText="保存"
cancelText="取消"
width={620}
destroyOnClose
>
<Form form={mappingForm} layout="vertical" onFinish={submitMapping}>
<Form.Item name="provider" label="来源" rules={[{ required: true }]}>
<Select
showSearch
options={providers.map((provider) => ({
value: provider,
label: providerLabel(provider),
}))}
/>
</Form.Item>
<Form.Item name="shopId" label="店铺 ID">
<Input placeholder="留空表示该来源全局映射" />
</Form.Item>
<Form.Item name="matchType" label="匹配方式" rules={[{ required: true }]}>
<Select options={MAPPING_MATCH_TYPE_OPTIONS} />
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, current) => prev.matchType !== current.matchType}>
{({ getFieldValue }) => {
const matchType =
(getFieldValue('matchType') as ChannelProductMapping['matchType'] | undefined) ||
'sku_code'
return (
<>
<Form.Item
name="platformSkuCode"
label="平台 SKU"
rules={
matchType === 'sku_name_series'
? []
: [{ required: true, message: '请输入平台 SKU' }]
}
>
<Input placeholder="平台原始 sku_code / productNo" />
</Form.Item>
<Form.Item
name="platformSkuName"
label="平台商品名"
rules={
matchType === 'sku_code'
? []
: [{ required: true, message: '该匹配方式必须填写平台商品名' }]
}
>
<Input placeholder="精确名称或数量系列文本,例如:指挥官密钥5个" />
</Form.Item>
</>
)
}}
</Form.Item>
<Form.Item
name="canonicalProductId"
label="标准商品"
rules={[{ required: true, message: '请选择标准商品' }]}
>
<Select showSearch options={productOptions} optionFilterProp="label" />
</Form.Item>
<Form.Item name="quantityMultiplier" label="数量倍率">
<InputNumber min={1} precision={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="confidence" label="置信度">
<Select
options={[
{ value: 'exact', label: '精确' },
{ value: 'rule', label: '规则' },
{ value: 'manual', label: '人工' },
]}
/>
</Form.Item>
<Form.Item name="source" label="来源方式">
<Select
options={[
{ value: 'manual', label: '人工' },
{ value: 'import', label: '导入' },
{ value: 'rule', label: '规则' },
]}
/>
</Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="note" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
/>
</>
)
}
@@ -0,0 +1,218 @@
/**
* 渠道映射 · 未归一队列 Tab。
*
* 每行是一个可以直接建映射的平台 SKU;筛选与分页状态由上层持有,
* 这里只渲染并回调,保证切 Tab 时筛选条件不丢。
*/
import { ReloadOutlined, SearchOutlined } from '@ant-design/icons'
import { Alert, Button, Input, Select, Space, Tag, Typography } from 'antd'
import type { TableColumnsType } from 'antd'
import PercentTable from '@/components/common/PercentTable'
import type { UnmappedMappingGroup } from '@/types/admin'
import { buildAdminTablePagination } from '@/utils/admin-pagination'
import { formatAdminDateTime } from '@/utils/admin-time'
import { FULFILLMENT_MODE_COLORS, FULFILLMENT_MODE_LABELS, providerLabel } from './shared'
export type MappingPrefill = {
provider?: string
shopId?: string
platformSkuCode?: string
platformSkuName?: string
}
type Props = {
groups: UnmappedMappingGroup[]
loading: boolean
fetching: boolean
page: number
pageSize: number
groupTotal: number
itemTotal: number
keyword: string
provider: string
providers: string[]
canConfig: boolean
sourceLabel: string
onKeywordChange: (value: string) => void
onProviderChange: (value: string) => void
onPageChange: (page: number, pageSize: number) => void
onRefresh: () => void
onCreateMapping: (prefill: MappingPrefill) => void
}
export default function ChannelMappingQueue({
groups,
loading,
fetching,
page,
pageSize,
groupTotal,
itemTotal,
keyword,
provider,
providers,
canConfig,
sourceLabel,
onKeywordChange,
onProviderChange,
onPageChange,
onRefresh,
onCreateMapping,
}: Props) {
const isEmpty = !loading && groupTotal === 0
const hasManual = groups.some((row) => row.fulfillmentMode === 'manual')
const columns: TableColumnsType<UnmappedMappingGroup> = [
{
title: '来源 / 店铺',
width: 220,
render: (_, row) => (
<Space direction="vertical" size={0} style={{ width: '100%' }}>
<Typography.Text>{providerLabel(row.provider)}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{row.shopId || '全局(未按店铺区分)'}
</Typography.Text>
</Space>
),
},
{
title: '平台 SKU',
width: 320,
render: (_, row) => (
<Space direction="vertical" size={0} style={{ width: '100%' }}>
<Typography.Text strong ellipsis={{ tooltip: row.skuCode || row.skuName }}>
{row.skuCode || '(无编码)'}
</Typography.Text>
<Typography.Text
type="secondary"
style={{ fontSize: 12 }}
ellipsis={{ tooltip: row.skuName }}
>
{row.skuName || '-'}
</Typography.Text>
</Space>
),
},
{
title: '履约方式',
dataIndex: 'fulfillmentMode',
width: 120,
render: (value: string) => (
<Tag color={FULFILLMENT_MODE_COLORS[value] || 'default'}>
{FULFILLMENT_MODE_LABELS[value] || value || '未知'}
</Tag>
),
},
{
title: '订单项',
dataIndex: 'itemCount',
width: 110,
render: (value: number) => (
<Space size={4}>
<Typography.Text strong>{value}</Typography.Text>
<Typography.Text type="secondary">项</Typography.Text>
</Space>
),
},
{
title: '最近订单时间',
dataIndex: 'lastSeenAt',
width: 180,
render: (value: string | null) => (value ? formatAdminDateTime(value) : '-'),
},
{
title: '操作',
width: 120,
render: (_, row) =>
canConfig ? (
<Button
size="small"
type="primary"
onClick={() =>
onCreateMapping({
provider: row.provider,
shopId: row.shopId,
platformSkuCode: row.skuCode,
platformSkuName: row.skuName,
})
}
>
建映射
</Button>
) : null,
},
]
return (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{isEmpty ? (
<Alert
type="success"
showIcon
message={`${sourceLabel}没有未归一的平台 SKU`}
description="订单项全部命中了标准商品映射;这里为空即代表销售与成本都能按商品归集。"
/>
) : (
<Alert
type="info"
showIcon
message="每行是一个可以直接建映射的平台 SKU"
description="队列来自每日成本扫描;建好映射后点右上角「回填订单项」把历史订单归一,已归一的异常会自动收口。"
/>
)}
{hasManual ? (
<Alert
type="warning"
showIcon
message="人工客服的未归一建议按内部 SKU 批量归一"
description="人工单的内部 SKU(快手 skuNick / 拼多多 outSkuSn)和平台 sku_code 不是同一个值,手建渠道映射通常抓不到历史单。请到「标准商品 → 归一人工商品」,按内部 SKU 生成 man: 商品并回填。"
/>
) : null}
<Space wrap>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="平台 SKU / 商品名 / 店铺"
value={keyword}
onChange={(event) => onKeywordChange(event.target.value)}
style={{ width: 260 }}
/>
<Select
allowClear
placeholder="来源"
value={provider || undefined}
onChange={(value) => onProviderChange(value || '')}
options={providers.map((item) => ({
value: item,
label: providerLabel(item),
}))}
style={{ width: 180 }}
/>
<Button icon={<ReloadOutlined />} loading={fetching} onClick={onRefresh}>
刷新队列
</Button>
</Space>
<PercentTable<UnmappedMappingGroup>
tableLayout="fixed"
rowKey={(row) => `${row.provider}-${row.shopId}-${row.fulfillmentMode}-${row.skuCode}`}
size="small"
columns={columns}
dataSource={groups}
loading={loading}
scroll={{ x: 1080 }}
pagination={buildAdminTablePagination({
current: page,
pageSize,
total: groupTotal,
showTotal: (total) => `共 ${total} 组 / ${itemTotal} 个订单项`,
onChange: (nextPage, nextPageSize) => onPageChange(nextPage, nextPageSize),
})}
/>
</Space>
)
}
@@ -0,0 +1,135 @@
/**
* 渠道映射的新建/编辑弹窗。
*
* 只负责表单渲染与校验,提交、关闭、回填提示都由上层(ChannelMappingPanel)决定。
*/
import { Form, Input, InputNumber, Modal, Select, Switch } from 'antd'
import type { FormInstance } from 'antd'
import type { ChannelProductMapping } from '@/types/admin'
import { MAPPING_MATCH_TYPE_OPTIONS, providerLabel, type MappingFormValues } from './shared'
type ProductOption = { value: number; label: string }
type Props = {
open: boolean
/** 非空表示编辑,为空表示新建。 */
editing: ChannelProductMapping | null
/** 从「未归一」队列进入时,标题与保存后提示不同。 */
fromQueue: boolean
form: FormInstance<MappingFormValues>
providers: string[]
productOptions: ProductOption[]
onSubmit: (values: MappingFormValues) => void
onCancel: () => void
}
export default function MappingFormModal({
open,
editing,
fromQueue,
form,
providers,
productOptions,
onSubmit,
onCancel,
}: Props) {
return (
<Modal
title={editing ? '编辑渠道映射' : fromQueue ? '为平台 SKU 建映射' : '新建渠道映射'}
open={open}
onCancel={onCancel}
onOk={() => form.submit()}
okText="保存"
cancelText="取消"
width={620}
destroyOnClose
>
<Form form={form} layout="vertical" onFinish={onSubmit}>
<Form.Item name="provider" label="来源" rules={[{ required: true }]}>
<Select
showSearch
options={providers.map((provider) => ({
value: provider,
label: providerLabel(provider),
}))}
/>
</Form.Item>
<Form.Item name="shopId" label="店铺 ID">
<Input placeholder="留空表示该来源全局映射" />
</Form.Item>
<Form.Item name="matchType" label="匹配方式" rules={[{ required: true }]}>
<Select options={MAPPING_MATCH_TYPE_OPTIONS} />
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, current) => prev.matchType !== current.matchType}>
{({ getFieldValue }) => {
const matchType =
(getFieldValue('matchType') as ChannelProductMapping['matchType'] | undefined) ||
'sku_code'
return (
<>
<Form.Item
name="platformSkuCode"
label="平台 SKU"
rules={
matchType === 'sku_name_series'
? []
: [{ required: true, message: '请输入平台 SKU' }]
}
>
<Input placeholder="平台原始 sku_code / productNo" />
</Form.Item>
<Form.Item
name="platformSkuName"
label="平台商品名"
rules={
matchType === 'sku_code'
? []
: [{ required: true, message: '该匹配方式必须填写平台商品名' }]
}
>
<Input placeholder="精确名称或数量系列文本,例如:指挥官密钥5个" />
</Form.Item>
</>
)
}}
</Form.Item>
<Form.Item
name="canonicalProductId"
label="标准商品"
rules={[{ required: true, message: '请选择标准商品' }]}
>
<Select showSearch options={productOptions} optionFilterProp="label" />
</Form.Item>
<Form.Item name="quantityMultiplier" label="数量倍率">
<InputNumber min={1} precision={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="confidence" label="置信度">
<Select
options={[
{ value: 'exact', label: '精确' },
{ value: 'rule', label: '规则' },
{ value: 'manual', label: '人工' },
]}
/>
</Form.Item>
<Form.Item name="source" label="来源方式">
<Select
options={[
{ value: 'manual', label: '人工' },
{ value: 'import', label: '导入' },
{ value: 'rule', label: '规则' },
]}
/>
</Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="note" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
)
}
@@ -113,6 +113,29 @@ export const SCOPE_OPTIONS = [
{ value: 'rule', label: '按接单规则' },
]
/** 从「未归一」队列建映射时带入的预填值。 */
export type MappingPrefill = {
provider?: string
shopId?: string
platformSkuCode?: string
platformSkuName?: string
}
/** 履约方式展示文案与颜色:渠道映射、成本异常、销售事实共用。 */
export const FULFILLMENT_MODE_LABELS: Record<string, string> = {
work_order: '接单平台',
affiliate_dash: '自动发货',
legacy_platform: '历史平台',
manual: '人工客服',
}
export const FULFILLMENT_MODE_COLORS: Record<string, string> = {
work_order: 'blue',
affiliate_dash: 'green',
legacy_platform: 'default',
manual: 'orange',
}
export const PROVIDER_LABELS: Record<string, string> = {
'91kaquan': '91 卡券(快手)',
pdd: '拼多多',