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:
@@ -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: '拼多多',
|
||||
|
||||
Reference in New Issue
Block a user