优化接单商品匹配
This commit is contained in:
@@ -11,6 +11,7 @@ import FinancePanel from './panels/FinancePanel'
|
||||
import LevelsPanel from './panels/LevelsPanel'
|
||||
import NotificationsPanel from './panels/NotificationsPanel'
|
||||
import ProductRulesPanel from './panels/ProductRulesPanel'
|
||||
import ProductMatchPanel from './panels/ProductMatchPanel'
|
||||
import WorkersPanel from './panels/WorkersPanel'
|
||||
import WorkOrdersPanel from './panels/WorkOrdersPanel'
|
||||
|
||||
@@ -48,7 +49,8 @@ export default function AdminWorkerPlatformPage() {
|
||||
? [{ key: 'orders', label: '接单工单', children: <WorkOrdersPanel /> }]
|
||||
: [
|
||||
{ key: 'orders', label: '接单工单', children: <WorkOrdersPanel /> },
|
||||
{ key: 'rules', label: '物品规则', children: <ProductRulesPanel /> },
|
||||
{ key: 'rules', label: '接单模板', children: <ProductRulesPanel /> },
|
||||
{ key: 'product-match', label: '商品匹配', children: <ProductMatchPanel /> },
|
||||
{ key: 'categories', label: '分类', children: <CategoriesPanel /> },
|
||||
{ key: 'workers', label: '打手', children: <WorkersPanel /> },
|
||||
{ key: 'finance', label: '资金', children: <FinancePanel /> },
|
||||
@@ -69,6 +71,7 @@ function loadActiveTabPreference(): string {
|
||||
const saved = localStorage.getItem(ACTIVE_TAB_STORAGE_KEY)
|
||||
return saved === 'orders' ||
|
||||
saved === 'rules' ||
|
||||
saved === 'product-match' ||
|
||||
saved === 'categories' ||
|
||||
saved === 'workers' ||
|
||||
saved === 'finance' ||
|
||||
@@ -86,6 +89,7 @@ function isWorkerPlatformTab(tab: string | null): tab is string {
|
||||
return [
|
||||
'orders',
|
||||
'rules',
|
||||
'product-match',
|
||||
'categories',
|
||||
'workers',
|
||||
'finance',
|
||||
|
||||
@@ -0,0 +1,623 @@
|
||||
import { DeleteOutlined, PlayCircleOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Descriptions,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import { useState } from 'react'
|
||||
|
||||
import JsonPreview from '@/components/admin/JsonPreview'
|
||||
import {
|
||||
deleteAdminWorkProductRuleMapping,
|
||||
fetchAdminKuaishouIndustryShops,
|
||||
fetchAdminKuaishouMatchSources,
|
||||
fetchAdminWorkProductMatchLogs,
|
||||
fetchAdminWorkProductRuleMappings,
|
||||
fetchAdminWorkProductRules,
|
||||
saveAdminWorkProductRuleMapping,
|
||||
testAdminKuaishouProductMatch,
|
||||
} from '@/services/admin'
|
||||
import type {
|
||||
KuaishouMatchSource,
|
||||
WorkProductMatchLog,
|
||||
WorkProductRuleMapping,
|
||||
} from '@/types/worker-platform'
|
||||
import type { AdminKuaishouIndustryShopOption } from '@/types/admin'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
type MappingFormValues = {
|
||||
mappingId?: number
|
||||
ruleId: number
|
||||
sellerIds: string[]
|
||||
relItemId?: string
|
||||
itemTitle?: string
|
||||
relSkuId?: string
|
||||
skuNick?: string
|
||||
mappingType: 'product_default' | 'sku_exact' | 'sku_series'
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export default function ProductMatchPanel() {
|
||||
const { message } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const [mappingForm] = Form.useForm<MappingFormValues>()
|
||||
const [testPayload, setTestPayload] = useState('')
|
||||
const [testResult, setTestResult] = useState<
|
||||
Awaited<ReturnType<typeof testAdminKuaishouProductMatch>>['data'] | null
|
||||
>(null)
|
||||
const [selectedLog, setSelectedLog] = useState<WorkProductMatchLog | null>(null)
|
||||
|
||||
const rulesQuery = useQuery({
|
||||
queryKey: ['admin-worker-platform-product-rules', 'mapping-templates'],
|
||||
queryFn: () => fetchAdminWorkProductRules({ page: 1, pageSize: 100 }),
|
||||
})
|
||||
const mappingsQuery = useQuery({
|
||||
queryKey: ['admin-worker-platform-product-mappings'],
|
||||
queryFn: () => fetchAdminWorkProductRuleMappings(),
|
||||
})
|
||||
const shopsQuery = useQuery({
|
||||
queryKey: ['admin-kuaishou-industry-shops', 'product-match'],
|
||||
queryFn: fetchAdminKuaishouIndustryShops,
|
||||
})
|
||||
const sourcesQuery = useQuery({
|
||||
queryKey: ['admin-worker-platform-product-match-sources'],
|
||||
queryFn: () => fetchAdminKuaishouMatchSources(200),
|
||||
})
|
||||
const logsQuery = useQuery({
|
||||
queryKey: ['admin-worker-platform-product-match-logs'],
|
||||
queryFn: () => fetchAdminWorkProductMatchLogs(100),
|
||||
})
|
||||
|
||||
const rules = rulesQuery.data?.data.items || []
|
||||
const mappings = mappingsQuery.data?.data.items || []
|
||||
const sources = sourcesQuery.data?.data.items || []
|
||||
const logs = logsQuery.data?.data.items || []
|
||||
const shops = shopsQuery.data?.data.shops || []
|
||||
const shopNameBySellerId = createShopNameBySellerId(shops)
|
||||
const sellerIds = new Set([
|
||||
...shops.map((shop) => shop.sellerId),
|
||||
...sources.map((source) => source.sellerId),
|
||||
])
|
||||
const sellerOptions = Array.from(sellerIds)
|
||||
.filter(Boolean)
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
.map((sellerId) => ({ value: sellerId, label: formatShopLabel(sellerId, shopNameBySellerId) }))
|
||||
|
||||
function resetMappingForm() {
|
||||
mappingForm.resetFields()
|
||||
mappingForm.setFieldsValue({ mappingType: 'product_default', enabled: true })
|
||||
}
|
||||
|
||||
function fillMappingFromSource(
|
||||
source: KuaishouMatchSource,
|
||||
mappingType: 'product_default' | 'sku_exact' | 'sku_series',
|
||||
) {
|
||||
mappingForm.setFieldsValue({
|
||||
mappingId: undefined,
|
||||
sellerIds: [source.sellerId],
|
||||
relItemId: mappingType === 'product_default' ? source.relItemId : '',
|
||||
itemTitle: mappingType === 'product_default' ? source.itemTitle : '',
|
||||
relSkuId: mappingType === 'sku_exact' ? source.relSkuId : '',
|
||||
skuNick:
|
||||
mappingType === 'product_default'
|
||||
? ''
|
||||
: mappingType === 'sku_series'
|
||||
? normalizeSkuSeriesName(source.skuNick)
|
||||
: source.skuNick,
|
||||
mappingType,
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
function editMapping(mapping: WorkProductRuleMapping) {
|
||||
mappingForm.setFieldsValue({
|
||||
mappingId: mapping.mappingId,
|
||||
ruleId: mapping.ruleId,
|
||||
sellerIds: mapping.sellerIds,
|
||||
relItemId: mapping.relItemId,
|
||||
itemTitle: mapping.itemTitle,
|
||||
relSkuId: mapping.relSkuId,
|
||||
skuNick: mapping.skuNick,
|
||||
mappingType: mapping.mappingType,
|
||||
enabled: mapping.enabled,
|
||||
})
|
||||
}
|
||||
|
||||
async function saveMapping(values: MappingFormValues) {
|
||||
try {
|
||||
await saveAdminWorkProductRuleMapping(values)
|
||||
message.success(values.mappingId ? '商品映射已更新' : '商品映射已创建')
|
||||
resetMappingForm()
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-product-mappings'] })
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '保存商品映射失败')
|
||||
}
|
||||
}
|
||||
|
||||
function handleMappingValuesChange(changedValues: Partial<MappingFormValues>) {
|
||||
if (
|
||||
changedValues.sellerIds &&
|
||||
changedValues.sellerIds.length > 1 &&
|
||||
mappingForm.getFieldValue('mappingType') === 'product_default'
|
||||
) {
|
||||
mappingForm.setFieldValue('relItemId', '')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteMapping(mappingId: number) {
|
||||
try {
|
||||
await deleteAdminWorkProductRuleMapping(mappingId)
|
||||
message.success('商品映射已删除')
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-product-mappings'] })
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '删除商品映射失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function runMatchTest() {
|
||||
if (!testPayload.trim()) {
|
||||
message.warning('请选择或粘贴快手原始载荷')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await testAdminKuaishouProductMatch(testPayload)
|
||||
setTestResult(response.data)
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '匹配测试失败')
|
||||
}
|
||||
}
|
||||
|
||||
const mappingColumns: TableColumnsType<WorkProductRuleMapping> = [
|
||||
{
|
||||
title: '店铺 / 商品',
|
||||
key: 'product',
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text strong ellipsis={{ tooltip: row.itemTitle }}>
|
||||
{row.itemTitle || row.relItemId}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
店铺{' '}
|
||||
{row.sellerIds
|
||||
.map((sellerId) => formatShopLabel(sellerId, shopNameBySellerId))
|
||||
.join('、')}{' '}
|
||||
{row.relItemId ? `· 商品 ${row.relItemId}` : ''}
|
||||
</Typography.Text>
|
||||
{row.mappingType !== 'product_default' ? (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{row.mappingType === 'sku_series' ? 'SKU 系列' : 'SKU'} {row.skuNick || row.relSkuId}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '映射类型',
|
||||
dataIndex: 'mappingType',
|
||||
width: 110,
|
||||
render: (value) => (
|
||||
<Tag color={value === 'sku_exact' ? 'purple' : value === 'sku_series' ? 'cyan' : 'blue'}>
|
||||
{value === 'sku_exact'
|
||||
? 'SKU 精确'
|
||||
: value === 'sku_series'
|
||||
? 'SKU 数量系列'
|
||||
: '商品默认'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '接单模板',
|
||||
key: 'template',
|
||||
width: 170,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text>{row.productName || row.ruleKey}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{row.ruleKey}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'enabled',
|
||||
width: 80,
|
||||
render: (enabled) => (
|
||||
<Tag color={enabled ? 'green' : 'default'}>{enabled ? '启用' : '停用'}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 100,
|
||||
render: (_, row) => (
|
||||
<Space size={0}>
|
||||
<Button type="link" onClick={() => editMapping(row)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm title="删除此商品映射?" onConfirm={() => deleteMapping(row.mappingId)}>
|
||||
<Button type="text" danger icon={<DeleteOutlined />} aria-label="删除商品映射" />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const sourceColumns: TableColumnsType<KuaishouMatchSource> = [
|
||||
{
|
||||
title: '快手商品 / SKU',
|
||||
key: 'source',
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text strong ellipsis={{ tooltip: row.itemTitle }}>
|
||||
{row.itemTitle || row.relItemId}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
店铺 {formatShopLabel(row.sellerId, shopNameBySellerId)} · {row.skuNick || '商品默认'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '出现',
|
||||
dataIndex: 'seenCount',
|
||||
width: 70,
|
||||
render: (value) => `${value} 次`,
|
||||
},
|
||||
{
|
||||
title: '最近',
|
||||
dataIndex: 'lastSeenAt',
|
||||
width: 160,
|
||||
render: (value) => formatAdminDateTime(value),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 200,
|
||||
render: (_, row) => (
|
||||
<Space size={4} wrap>
|
||||
<Button type="link" onClick={() => fillMappingFromSource(row, 'product_default')}>
|
||||
设为商品默认
|
||||
</Button>
|
||||
<Button type="link" onClick={() => fillMappingFromSource(row, 'sku_exact')}>
|
||||
设为 SKU 精确
|
||||
</Button>
|
||||
<Button type="link" onClick={() => fillMappingFromSource(row, 'sku_series')}>
|
||||
设为 SKU 系列
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => {
|
||||
setTestPayload(JSON.stringify(row.rawPayload, null, 2))
|
||||
}}
|
||||
>
|
||||
测试
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const logColumns: TableColumnsType<WorkProductMatchLog> = [
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 165,
|
||||
render: (value) => formatAdminDateTime(value),
|
||||
},
|
||||
{
|
||||
title: '店铺 / 商品 / SKU',
|
||||
key: 'target',
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text ellipsis={{ tooltip: row.itemTitle }}>
|
||||
{row.itemTitle || '-'}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{formatShopLabel(row.sellerId, shopNameBySellerId)} ·{' '}
|
||||
{row.skuNick || row.relSkuId || '商品默认'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '结果',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: (value) => <MatchStatusTag status={value} />,
|
||||
},
|
||||
{
|
||||
title: '模板',
|
||||
key: 'rule',
|
||||
width: 170,
|
||||
render: (_, row) => row.productName || row.ruleKey || '-',
|
||||
},
|
||||
{
|
||||
title: '载荷',
|
||||
key: 'payload',
|
||||
width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" onClick={() => setSelectedLog(row)}>
|
||||
查看
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Card bordered={false} title="商品匹配">
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'mappings',
|
||||
label: `商品映射 (${mappings.length})`,
|
||||
children: (
|
||||
<div className="page-stack">
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(280px, 360px) minmax(0, 1fr)',
|
||||
gap: 20,
|
||||
alignItems: 'start',
|
||||
}}
|
||||
>
|
||||
<Form
|
||||
form={mappingForm}
|
||||
layout="vertical"
|
||||
initialValues={{ mappingType: 'product_default', enabled: true }}
|
||||
onValuesChange={handleMappingValuesChange}
|
||||
onFinish={saveMapping}
|
||||
>
|
||||
<Form.Item name="mappingId" hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="接单模板"
|
||||
name="ruleId"
|
||||
rules={[{ required: true, message: '请选择接单模板' }]}
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="选择可复用模板"
|
||||
options={rules.map((rule) => ({
|
||||
value: rule.ruleId,
|
||||
label: `${rule.productName || rule.ruleKey} (${rule.ruleKey})`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="映射类型" name="mappingType">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'product_default', label: '商品默认模板' },
|
||||
{ value: 'sku_series', label: 'SKU 数量系列' },
|
||||
{ value: 'sku_exact', label: 'SKU 精确覆盖' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="覆盖店铺"
|
||||
name="sellerIds"
|
||||
rules={[{ required: true, message: '至少选择一个店铺' }]}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
tokenSeparators={[',', ',']}
|
||||
placeholder="选择快手店铺,可多选"
|
||||
options={sellerOptions}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="关联商品 ID" name="relItemId">
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
placeholder="可填写多个;用逗号、中文逗号或换行分隔"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="快手大标题" name="itemTitle">
|
||||
<Input placeholder="可选;商品默认必填,同名 SKU 分流时填写" />
|
||||
</Form.Item>
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{({ getFieldValue }) =>
|
||||
getFieldValue('mappingType') === 'sku_exact' ? (
|
||||
<>
|
||||
<Form.Item label="关联 SKU ID" name="relSkuId">
|
||||
<Input placeholder="ext.relSkuId,0 自动忽略" />
|
||||
</Form.Item>
|
||||
<Form.Item label="具体 SKU 名称" name="skuNick">
|
||||
<Input placeholder="ext.skuNick,完整精确匹配" />
|
||||
</Form.Item>
|
||||
</>
|
||||
) : getFieldValue('mappingType') === 'sku_series' ? (
|
||||
<Form.Item label="SKU 系列名称" name="skuNick">
|
||||
<Input placeholder="例如:指挥官密钥;自动覆盖 1个、10个等数量规格" />
|
||||
</Form.Item>
|
||||
) : null
|
||||
}
|
||||
</Form.Item>
|
||||
<Form.Item label="启用" name="enabled" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
保存映射
|
||||
</Button>
|
||||
<Button onClick={resetMappingForm}>新建</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
<Table<WorkProductRuleMapping>
|
||||
rowKey="mappingId"
|
||||
loading={mappingsQuery.isLoading}
|
||||
columns={mappingColumns}
|
||||
dataSource={mappings}
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Space style={{ marginBottom: 10 }}>
|
||||
<Typography.Text strong>已接收的快手商品与 SKU</Typography.Text>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={sourcesQuery.isFetching}
|
||||
onClick={() => sourcesQuery.refetch()}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
<Table<KuaishouMatchSource>
|
||||
rowKey={(row) =>
|
||||
`${row.sellerId}-${row.relItemId}-${row.itemTitle}-${row.relSkuId}-${row.skuNick}`
|
||||
}
|
||||
loading={sourcesQuery.isLoading}
|
||||
columns={sourceColumns}
|
||||
dataSource={sources}
|
||||
pagination={{ pageSize: 10, showSizeChanger: false }}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'test',
|
||||
label: '匹配测试',
|
||||
children: (
|
||||
<div className="page-stack">
|
||||
<Space wrap>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
style={{ minWidth: 360 }}
|
||||
placeholder="选择已接收的快手原始载荷"
|
||||
options={sources.map((source, index) => ({
|
||||
value: index,
|
||||
label: `${formatShopLabel(source.sellerId, shopNameBySellerId)} · ${source.itemTitle} · ${source.skuNick || '商品默认'}`,
|
||||
}))}
|
||||
onChange={(index) => {
|
||||
const source = sources[Number(index)]
|
||||
if (source) setTestPayload(JSON.stringify(source.rawPayload, null, 2))
|
||||
}}
|
||||
/>
|
||||
<Button type="primary" icon={<PlayCircleOutlined />} onClick={runMatchTest}>
|
||||
测试匹配
|
||||
</Button>
|
||||
</Space>
|
||||
<Input.TextArea
|
||||
value={testPayload}
|
||||
onChange={(event) => setTestPayload(event.target.value)}
|
||||
rows={16}
|
||||
placeholder="粘贴快手 send-code 原始载荷"
|
||||
/>
|
||||
{testResult ? (
|
||||
<div className="page-stack">
|
||||
<Descriptions bordered size="small" column={{ xs: 1, md: 2 }}>
|
||||
<Descriptions.Item label="结果">
|
||||
<MatchStatusTag status={testResult.status} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="命中模板">
|
||||
{testResult.rule?.productName || testResult.rule?.ruleKey || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="店铺">
|
||||
{testResult.context.sellerId
|
||||
? formatShopLabel(testResult.context.sellerId, shopNameBySellerId)
|
||||
: '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="具体 SKU">
|
||||
{testResult.context.skuNick || '-'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<JsonPreview
|
||||
value={{ mappingId: testResult.mappingId, candidates: testResult.candidates }}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'logs',
|
||||
label: `匹配日志 (${logs.length})`,
|
||||
children: (
|
||||
<div className="page-stack">
|
||||
<Space>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={logsQuery.isFetching}
|
||||
onClick={() => logsQuery.refetch()}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
<Typography.Text type="secondary">
|
||||
仅记录包含快手发码上下文的真实匹配
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
<Table<WorkProductMatchLog>
|
||||
rowKey="logId"
|
||||
loading={logsQuery.isLoading}
|
||||
columns={logColumns}
|
||||
dataSource={logs}
|
||||
pagination={{ pageSize: 20, showSizeChanger: false }}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Modal
|
||||
title="匹配原始载荷"
|
||||
open={Boolean(selectedLog)}
|
||||
footer={null}
|
||||
width={760}
|
||||
onCancel={() => setSelectedLog(null)}
|
||||
>
|
||||
<JsonPreview value={selectedLog?.rawPayload} />
|
||||
</Modal>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function MatchStatusTag({ status }: { status: string }) {
|
||||
const color = status === 'matched' ? 'green' : status === 'ambiguous' ? 'orange' : 'red'
|
||||
const label = status === 'matched' ? '命中' : status === 'ambiguous' ? '冲突' : '未命中'
|
||||
return <Tag color={color}>{label}</Tag>
|
||||
}
|
||||
|
||||
function normalizeSkuSeriesName(value: string) {
|
||||
return value
|
||||
.normalize('NFKC')
|
||||
.replace(/[\s\u3000]+/g, '')
|
||||
.replace(/(?:\d+|[零一二三四五六七八九十百千两]+)(?:个|份|张|枚)/g, '')
|
||||
}
|
||||
|
||||
function createShopNameBySellerId(shops: AdminKuaishouIndustryShopOption[]) {
|
||||
const names = new Map<string, string>()
|
||||
for (const shop of shops) {
|
||||
const name = String(shop.shopName || '').trim()
|
||||
if (!name) continue
|
||||
const sellerId = String(shop.sellerId || '').trim()
|
||||
const shopId = String(shop.shopId || '').trim()
|
||||
if (sellerId) names.set(sellerId, name)
|
||||
if (shopId) names.set(shopId, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
function formatShopLabel(sellerId: string, shopNameBySellerId: Map<string, string>) {
|
||||
const name = shopNameBySellerId.get(sellerId)
|
||||
return name ? `${name}(${sellerId})` : sellerId
|
||||
}
|
||||
@@ -159,9 +159,10 @@ export default function ProductRulesPanel() {
|
||||
await saveAdminWorkProductRule({
|
||||
...payload,
|
||||
ruleId: editingRule?.ruleId,
|
||||
match: editingRule?.match,
|
||||
unitPrice: unitMode ? Number(values.unitPrice || 0) : 0,
|
||||
})
|
||||
message.success(editingRule ? '物品规则已更新' : '物品规则已创建')
|
||||
message.success(editingRule ? '接单模板已更新' : '接单模板已创建')
|
||||
resetRuleForm()
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
@@ -218,7 +219,7 @@ export default function ProductRulesPanel() {
|
||||
if (editingRule?.ruleId === rule.ruleId) {
|
||||
resetRuleForm()
|
||||
}
|
||||
message.success('物品规则已删除')
|
||||
message.success('接单模板已删除')
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['admin-worker-platform-product-rules'],
|
||||
})
|
||||
@@ -268,20 +269,13 @@ export default function ProductRulesPanel() {
|
||||
|
||||
const columns: TableColumnsType<WorkProductRule> = [
|
||||
{
|
||||
title: '规则 / 商品',
|
||||
title: '模板 / 商品',
|
||||
key: 'rule',
|
||||
width: '29%',
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text
|
||||
strong
|
||||
title={row.match.skuNick || row.match.itemTitle || row.productName || row.skuCode}
|
||||
>
|
||||
{row.match.skuNick ||
|
||||
row.match.itemTitle ||
|
||||
row.productName ||
|
||||
row.skuCode ||
|
||||
row.ruleKey}
|
||||
<Typography.Text strong title={row.productName || row.ruleKey}>
|
||||
{row.productName || row.ruleKey}
|
||||
</Typography.Text>
|
||||
<Space size={4} wrap style={{ marginTop: 2 }}>
|
||||
<Tag color="blue" style={{ margin: 0, fontSize: 11, lineHeight: '18px' }}>
|
||||
@@ -293,11 +287,6 @@ export default function ProductRulesPanel() {
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</Space>
|
||||
{row.match.itemTitle || row.match.skuNick ? (
|
||||
<Typography.Text type="secondary" ellipsis={{ tooltip: true }} style={{ fontSize: 11 }}>
|
||||
{[row.match.itemTitle, row.match.skuNick].filter(Boolean).join(' / ')}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -442,7 +431,7 @@ export default function ProductRulesPanel() {
|
||||
title={
|
||||
<Space size={6}>
|
||||
<FormOutlined style={{ color: '#1677ff' }} />
|
||||
<span>{editingRule ? `编辑规则 · ${editingRule.ruleKey}` : '新建物品规则'}</span>
|
||||
<span>{editingRule ? `编辑模板 · ${editingRule.ruleKey}` : '新建接单模板'}</span>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
@@ -499,71 +488,13 @@ export default function ProductRulesPanel() {
|
||||
<Input placeholder="kuaishou" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="店铺" name="shopId">
|
||||
<Input placeholder="留空通配" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="SKU" name="skuCode">
|
||||
<Input placeholder="优先精确匹配" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="匹配方式" name="matchType">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'contains', label: '包含' },
|
||||
{ value: 'exact', label: '精确' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="商品名称" name="productName" className="span-2">
|
||||
<Input placeholder="旧规则兼容字段;快手订单请配置下方大标题和 SKU" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="快手大标题" name="itemTitle" className="span-2">
|
||||
<Input placeholder="itemTitle,例如:和平精英密钥……" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="大标题匹配" name="itemTitleMatchType">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'exact', label: '精确' },
|
||||
{ value: 'contains', label: '包含' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="具体 SKU 名称" name="skuNick">
|
||||
<Input placeholder="ext.skuNick,例如:1个精英尊尚专属礼盒" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="SKU 名称匹配" name="skuNickMatchType">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'exact', label: '精确' },
|
||||
{ value: 'contains', label: '包含' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="快手卖家 ID" name="sellerId">
|
||||
<Input placeholder="sellerId,留空通配" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="快手商品 ID" name="itemId">
|
||||
<Input placeholder="itemId,精确匹配" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="快手 SKU ID" name="skuId">
|
||||
<Input placeholder="skuId,精确匹配" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="关联商品 ID" name="relItemId">
|
||||
<Input placeholder="ext.relItemId,精确匹配" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="关联 SKU ID" name="relSkuId">
|
||||
<Input placeholder="ext.relSkuId,精确匹配" />
|
||||
<Form.Item
|
||||
label="模板名称"
|
||||
name="productName"
|
||||
className="span-2"
|
||||
rules={[{ required: true, message: '请输入模板名称' }]}
|
||||
>
|
||||
<Input placeholder="例如:指挥官密钥标准模板" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="所属分类" name="categoryId">
|
||||
@@ -714,7 +645,7 @@ export default function ProductRulesPanel() {
|
||||
title={
|
||||
<Space align="center" size={10}>
|
||||
<Typography.Text strong style={{ fontSize: 15 }}>
|
||||
物品规则列表
|
||||
接单模板列表
|
||||
</Typography.Text>
|
||||
<Tag color="blue" style={{ margin: 0 }}>
|
||||
{rulesPagination?.total || 0} 条规则
|
||||
@@ -725,7 +656,7 @@ export default function ProductRulesPanel() {
|
||||
<Space size={8} wrap>
|
||||
<Input.Search
|
||||
allowClear
|
||||
placeholder="搜索规则/商品/SKU/店铺"
|
||||
placeholder="搜索模板/商品/SKU/店铺"
|
||||
style={{ width: 200 }}
|
||||
value={keywordInput}
|
||||
onChange={(event) => {
|
||||
|
||||
@@ -8,6 +8,9 @@ import type {
|
||||
WorkOrderShare,
|
||||
WorkOrderStatistics,
|
||||
WorkProductRule,
|
||||
WorkProductRuleMapping,
|
||||
KuaishouMatchSource,
|
||||
WorkProductMatchLog,
|
||||
WorkerFinanceConfig,
|
||||
WorkerFinanceRequest,
|
||||
WorkerPlatformNotificationConfig,
|
||||
@@ -135,6 +138,63 @@ export function deleteAdminWorkProductRule(ruleId: number) {
|
||||
return apiDelete<{ deleted: boolean }>(`/api/v1/admin/worker-platform/product-rules/${ruleId}`)
|
||||
}
|
||||
|
||||
export function fetchAdminWorkProductRuleMappings() {
|
||||
return apiGet<{ items: WorkProductRuleMapping[] }>(
|
||||
'/api/v1/admin/worker-platform/product-mappings',
|
||||
)
|
||||
}
|
||||
|
||||
export function saveAdminWorkProductRuleMapping(payload: {
|
||||
mappingId?: number
|
||||
ruleId: number
|
||||
sellerIds: string[]
|
||||
relItemId?: string
|
||||
itemTitle?: string
|
||||
relSkuId?: string
|
||||
skuNick?: string
|
||||
mappingType: 'product_default' | 'sku_exact' | 'sku_series'
|
||||
enabled?: boolean
|
||||
}) {
|
||||
return apiPost<{ mapping: WorkProductRuleMapping }>(
|
||||
'/api/v1/admin/worker-platform/product-mappings',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function deleteAdminWorkProductRuleMapping(mappingId: number) {
|
||||
return apiDelete<{ deleted: boolean }>(
|
||||
`/api/v1/admin/worker-platform/product-mappings/${mappingId}`,
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAdminKuaishouMatchSources(limit = 100) {
|
||||
return apiGet<{ items: KuaishouMatchSource[] }>(
|
||||
'/api/v1/admin/worker-platform/product-match-sources',
|
||||
{
|
||||
limit,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export function testAdminKuaishouProductMatch(rawPayload: string) {
|
||||
return apiPost<{
|
||||
context: Record<string, string>
|
||||
status: 'matched' | 'unmatched' | 'ambiguous'
|
||||
mappingId: number | null
|
||||
rule: WorkProductRule | null
|
||||
candidates: Array<{ ruleKey: string; score: number; mappingId: number | null }>
|
||||
}>('/api/v1/admin/worker-platform/product-match-test', { rawPayload })
|
||||
}
|
||||
|
||||
export function fetchAdminWorkProductMatchLogs(limit = 100) {
|
||||
return apiGet<{ items: WorkProductMatchLog[] }>(
|
||||
'/api/v1/admin/worker-platform/product-match-logs',
|
||||
{
|
||||
limit,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export function reprocessAdminKuaishouWorkOrderMatches(limit = 100) {
|
||||
return apiPost<{ scannedCount: number; createdCount: number; skippedCount: number }>(
|
||||
'/api/v1/admin/worker-platform/orders/reprocess-matches',
|
||||
|
||||
@@ -226,6 +226,55 @@ export type WorkProductRule = {
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type WorkProductRuleMapping = {
|
||||
mappingId: number
|
||||
ruleId: number
|
||||
ruleKey: string
|
||||
productName: string
|
||||
sellerIds: string[]
|
||||
sellerId: string
|
||||
relItemId: string
|
||||
itemTitle: string
|
||||
relSkuId: string
|
||||
skuNick: string
|
||||
mappingType: 'product_default' | 'sku_exact' | 'sku_series'
|
||||
enabled: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type KuaishouMatchSource = {
|
||||
sellerId: string
|
||||
relItemId: string
|
||||
itemTitle: string
|
||||
relSkuId: string
|
||||
skuNick: string
|
||||
sampleOid: string
|
||||
lastSeenAt: string
|
||||
seenCount: number
|
||||
rawPayload: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type WorkProductMatchLog = {
|
||||
logId: number
|
||||
orderId: number | null
|
||||
orderItemId: number | null
|
||||
source: string
|
||||
sellerId: string
|
||||
relItemId: string
|
||||
itemTitle: string
|
||||
relSkuId: string
|
||||
skuNick: string
|
||||
status: 'matched' | 'unmatched' | 'ambiguous' | string
|
||||
ruleId: number | null
|
||||
mappingId: number | null
|
||||
ruleKey: string
|
||||
productName: string
|
||||
candidates: Array<{ ruleKey?: string; score?: number; mappingId?: number | null }>
|
||||
rawPayload: Record<string, unknown>
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type UploadedFile = {
|
||||
objectKey: string
|
||||
url: string
|
||||
|
||||
Reference in New Issue
Block a user