feat(admin): 重构接单模板与商品绑定内联编辑 UI 布局
- 优化接单模板与模板列表 6:4 比例自适应布局 - 适用商品绑定支持左侧直接内联展开编辑,免弹窗模式 - 支持绑定规则多标签快速切换、实时新增与删除 - 基础表单与商品绑定统一采用 3 列网格布局,严格几何对齐 - 「启用拼单」开关及参数行内紧凑展开,移除多余背景框与纵向滚动条
This commit is contained in:
@@ -1,15 +1,12 @@
|
||||
import { DeleteOutlined, PlayCircleOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import { PlayCircleOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Descriptions,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
@@ -20,66 +17,32 @@ import {
|
||||
} from 'antd'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
|
||||
import JsonPreview from '@/components/admin/JsonPreview'
|
||||
import {
|
||||
deleteAdminWorkProductRuleMapping,
|
||||
fetchAdminKuaishouIndustryShops,
|
||||
fetchAdminKuaishouMatchSources,
|
||||
fetchAdminWorkerProductMatchConfig,
|
||||
fetchAdminWorkProductMatchLogs,
|
||||
fetchAdminWorkProductRuleMappings,
|
||||
fetchAdminWorkProductRules,
|
||||
saveAdminWorkProductRuleMapping,
|
||||
saveAdminWorkerProductMatchConfig,
|
||||
testAdminKuaishouProductMatch,
|
||||
} from '@/services/admin'
|
||||
import type {
|
||||
KuaishouMatchSource,
|
||||
WorkProductMatchLog,
|
||||
WorkProductRuleMapping,
|
||||
WorkerProductMatchConfig,
|
||||
} from '@/types/worker-platform'
|
||||
import type { KuaishouMatchSource, WorkProductMatchLog } from '@/types/worker-platform'
|
||||
import type { AdminKuaishouIndustryShopOption } from '@/types/admin'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
type MappingFormValues = {
|
||||
mappingId?: number
|
||||
ruleId: number
|
||||
sellerIds: string[]
|
||||
productScope: 'all_products' | 'selected_products'
|
||||
relItemId?: string
|
||||
itemTitle?: string
|
||||
relSkuId?: string
|
||||
skuNick?: string
|
||||
skuMatchMode: 'fuzzy' | 'exact'
|
||||
mappingType: 'product_default' | 'sku_exact' | 'sku_series'
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
type SellerOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export default function ProductMatchPanel() {
|
||||
const { message } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const [mappingForm] = Form.useForm<MappingFormValues>()
|
||||
const navigate = useNavigate()
|
||||
const [testPayload, setTestPayload] = useState('')
|
||||
const [testResult, setTestResult] = useState<
|
||||
Awaited<ReturnType<typeof testAdminKuaishouProductMatch>>['data'] | null
|
||||
>(null)
|
||||
const [selectedLog, setSelectedLog] = useState<WorkProductMatchLog | null>(null)
|
||||
const [activeTab, setActiveTab] = useState('sources')
|
||||
|
||||
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,
|
||||
@@ -97,111 +60,23 @@ export default function ProductMatchPanel() {
|
||||
queryFn: fetchAdminWorkerProductMatchConfig,
|
||||
})
|
||||
|
||||
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 matchConfig = matchConfigQuery.data?.data
|
||||
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',
|
||||
productScope: 'selected_products',
|
||||
skuMatchMode: 'fuzzy',
|
||||
enabled: true,
|
||||
function bindToTemplate(source: KuaishouMatchSource) {
|
||||
const params = new URLSearchParams({
|
||||
tab: 'rules',
|
||||
bind: '1',
|
||||
sellerId: source.sellerId,
|
||||
relItemId: source.relItemId || '',
|
||||
itemTitle: source.itemTitle || '',
|
||||
relSkuId: source.relSkuId || '',
|
||||
skuNick: source.skuNick || '',
|
||||
})
|
||||
}
|
||||
|
||||
function fillMappingFromSource(
|
||||
source: KuaishouMatchSource,
|
||||
mappingType: 'product_default' | 'sku_exact' | 'sku_series',
|
||||
) {
|
||||
mappingForm.setFieldsValue({
|
||||
mappingId: undefined,
|
||||
sellerIds: [source.sellerId],
|
||||
productScope: mappingType === 'product_default' ? 'selected_products' : 'all_products',
|
||||
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,
|
||||
skuMatchMode: 'fuzzy',
|
||||
mappingType,
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
function editMapping(mapping: WorkProductRuleMapping) {
|
||||
mappingForm.setFieldsValue({
|
||||
mappingId: mapping.mappingId,
|
||||
ruleId: mapping.ruleId,
|
||||
sellerIds: mapping.sellerIds,
|
||||
productScope: mapping.relItemId || mapping.itemTitle ? 'selected_products' : 'all_products',
|
||||
relItemId: mapping.relItemId,
|
||||
itemTitle: mapping.itemTitle,
|
||||
relSkuId: mapping.relSkuId,
|
||||
skuNick: mapping.skuNick,
|
||||
skuMatchMode: mapping.skuMatchMode,
|
||||
mappingType: mapping.mappingType,
|
||||
enabled: mapping.enabled,
|
||||
})
|
||||
}
|
||||
|
||||
async function saveMapping(values: MappingFormValues) {
|
||||
try {
|
||||
const { productScope: _productScope, ...payload } = values
|
||||
await saveAdminWorkProductRuleMapping(payload)
|
||||
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.mappingType) {
|
||||
const isProductDefault = changedValues.mappingType === 'product_default'
|
||||
mappingForm.setFieldsValue({
|
||||
productScope: isProductDefault ? 'selected_products' : 'all_products',
|
||||
...(isProductDefault ? {} : { relItemId: '', itemTitle: '' }),
|
||||
})
|
||||
}
|
||||
if (changedValues.productScope === 'all_products') {
|
||||
mappingForm.setFieldsValue({ relItemId: '', itemTitle: '' })
|
||||
}
|
||||
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 : '删除商品映射失败')
|
||||
}
|
||||
void navigate(`/admin/worker-platform?${params.toString()}`)
|
||||
}
|
||||
|
||||
async function runMatchTest() {
|
||||
@@ -218,10 +93,9 @@ export default function ProductMatchPanel() {
|
||||
}
|
||||
|
||||
async function updateLegacyFallback(legacyFallbackEnabled: boolean) {
|
||||
const nextConfig: WorkerProductMatchConfig = { legacyFallbackEnabled }
|
||||
try {
|
||||
await saveAdminWorkerProductMatchConfig(nextConfig)
|
||||
message.success(legacyFallbackEnabled ? '旧规则兼容匹配已开启' : '旧规则兼容匹配已关闭')
|
||||
await saveAdminWorkerProductMatchConfig({ legacyFallbackEnabled })
|
||||
message.success(legacyFallbackEnabled ? '兼容旧模板匹配已开启' : '兼容旧模板匹配已关闭')
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['admin-worker-platform-product-match-config'],
|
||||
})
|
||||
@@ -230,85 +104,6 @@ export default function ProductMatchPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
const mappingColumns: TableColumnsType<WorkProductRuleMapping> = [
|
||||
{
|
||||
title: '店铺 / 商品',
|
||||
key: 'product',
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text strong ellipsis={{ tooltip: mappingProductScopeTooltip(row) }}>
|
||||
{mappingProductScopeLabel(row)}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
店铺{' '}
|
||||
{row.sellerIds
|
||||
.map((sellerId) => formatShopLabel(sellerId, shopNameBySellerId))
|
||||
.join('、')}{' '}
|
||||
· {mappingProductScopeLabel(row)}
|
||||
</Typography.Text>
|
||||
{row.mappingType !== 'product_default' ? (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{row.mappingType === 'sku_series' ? 'SKU 系列' : 'SKU'} {row.skuNick || row.relSkuId}
|
||||
{row.mappingType === 'sku_series'
|
||||
? ` · ${row.skuMatchMode === 'exact' ? '精确' : '模糊'}`
|
||||
: ''}
|
||||
</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',
|
||||
@@ -339,22 +134,17 @@ export default function ProductMatchPanel() {
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 200,
|
||||
width: 160,
|
||||
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 type="link" onClick={() => bindToTemplate(row)}>
|
||||
绑定到模板
|
||||
</Button>
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => {
|
||||
setTestPayload(JSON.stringify(row.rawPayload, null, 2))
|
||||
setActiveTab('test')
|
||||
}}
|
||||
>
|
||||
测试
|
||||
@@ -411,184 +201,49 @@ export default function ProductMatchPanel() {
|
||||
]
|
||||
|
||||
return (
|
||||
<Card bordered={false} title="商品匹配">
|
||||
<Card bordered={false} title="匹配诊断">
|
||||
<Space align="center" style={{ marginBottom: 16 }}>
|
||||
<Switch
|
||||
checked={matchConfig?.legacyFallbackEnabled === true}
|
||||
loading={matchConfigQuery.isLoading || matchConfigQuery.isFetching}
|
||||
onChange={updateLegacyFallback}
|
||||
/>
|
||||
<Typography.Text>启用旧规则兼容匹配</Typography.Text>
|
||||
<Typography.Text type="secondary">默认关闭;关闭后快手订单仅按商品映射匹配</Typography.Text>
|
||||
<Typography.Text>兼容旧模板匹配(迁移用)</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
默认关闭;正常快手订单按「接单模板 → 商品绑定」匹配
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={[
|
||||
{
|
||||
key: 'mappings',
|
||||
label: `商品映射 (${mappings.length})`,
|
||||
key: 'sources',
|
||||
label: `已接收商品 (${sources.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',
|
||||
productScope: 'selected_products',
|
||||
skuMatchMode: 'fuzzy',
|
||||
enabled: true,
|
||||
}}
|
||||
onValuesChange={handleMappingValuesChange}
|
||||
onFinish={saveMapping}
|
||||
<Space>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={sourcesQuery.isFetching}
|
||||
onClick={() => sourcesQuery.refetch()}
|
||||
>
|
||||
<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: '至少选择一个店铺' }]}
|
||||
>
|
||||
<ShopSelector options={sellerOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{({ getFieldValue }) => {
|
||||
const mappingType = getFieldValue('mappingType')
|
||||
const isProductDefault = mappingType === 'product_default'
|
||||
const productScope = getFieldValue('productScope')
|
||||
const showProductScope =
|
||||
isProductDefault || productScope === 'selected_products'
|
||||
return (
|
||||
<>
|
||||
{!isProductDefault ? (
|
||||
<Form.Item label="商品范围" name="productScope">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'all_products', label: '全部商品' },
|
||||
{ value: 'selected_products', label: '指定商品' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{showProductScope ? (
|
||||
<>
|
||||
<Form.Item label="关联商品 ID" name="relItemId">
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
placeholder="可填写多个;用逗号、中文逗号或换行分隔"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="快手大标题" name="itemTitle">
|
||||
<Input
|
||||
placeholder={
|
||||
isProductDefault
|
||||
? '商品默认规则需填写大标题或关联商品 ID'
|
||||
: '可选;同名 SKU 需要按父商品分流时填写'
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null}
|
||||
{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>
|
||||
</>
|
||||
) : mappingType === 'sku_series' ? (
|
||||
<>
|
||||
<Form.Item label="SKU 匹配方式" name="skuMatchMode">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'fuzzy', label: '模糊匹配(默认)' },
|
||||
{ value: 'exact', label: '精确匹配' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<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={{ pageSize: 10, showSizeChanger: false, showTotal: (total) => `共 ${total} 条` }}
|
||||
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>
|
||||
刷新
|
||||
</Button>
|
||||
<Typography.Text type="secondary">
|
||||
发现新商品后,点击「绑定到模板」在接单模板页配置匹配
|
||||
</Typography.Text>
|
||||
</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>
|
||||
),
|
||||
},
|
||||
@@ -697,73 +352,6 @@ function MatchStatusTag({ status }: { status: string }) {
|
||||
return <Tag color={color}>{label}</Tag>
|
||||
}
|
||||
|
||||
function ShopSelector({
|
||||
value = [],
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
value?: string[]
|
||||
onChange?: (nextValue: string[]) => void
|
||||
options: SellerOption[]
|
||||
}) {
|
||||
const optionValues = options.map((option) => option.value)
|
||||
const selectedValues = Array.isArray(value) ? value.map(String) : []
|
||||
const selectedOptionValues = selectedValues.filter((sellerId) => optionValues.includes(sellerId))
|
||||
const otherValues = selectedValues.filter((sellerId) => !optionValues.includes(sellerId))
|
||||
const allSelected = optionValues.length > 0 && selectedOptionValues.length === optionValues.length
|
||||
const partiallySelected = selectedOptionValues.length > 0 && !allSelected
|
||||
|
||||
return (
|
||||
<div className="page-stack" style={{ gap: 8 }}>
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
disabled={optionValues.length === 0}
|
||||
indeterminate={partiallySelected}
|
||||
onChange={(event) =>
|
||||
onChange?.(event.target.checked ? [...otherValues, ...optionValues] : otherValues)
|
||||
}
|
||||
>
|
||||
全选店铺({optionValues.length})
|
||||
</Checkbox>
|
||||
<Checkbox.Group
|
||||
value={selectedValues}
|
||||
onChange={(nextValues) => onChange?.(nextValues.map(String))}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))',
|
||||
gap: 8,
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<Checkbox key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Checkbox.Group>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeSkuSeriesName(value: string) {
|
||||
return value
|
||||
.normalize('NFKC')
|
||||
.replace(/[\s\u3000]+/g, '')
|
||||
.replace(/(?:\d+|[零一二三四五六七八九十百千两]+)(?:个|份|张|枚)/g, '')
|
||||
}
|
||||
|
||||
function mappingProductScopeLabel(mapping: WorkProductRuleMapping) {
|
||||
if (mapping.itemTitle) return mapping.itemTitle
|
||||
const productIds = String(mapping.relItemId || '')
|
||||
.split(/[,,;;\s\n\r]+/)
|
||||
.filter(Boolean)
|
||||
return productIds.length > 0 ? `指定商品(${productIds.length} 个)` : '全部商品'
|
||||
}
|
||||
|
||||
function mappingProductScopeTooltip(mapping: WorkProductRuleMapping) {
|
||||
return mapping.itemTitle || mapping.relItemId || '覆盖店铺内的全部商品'
|
||||
}
|
||||
|
||||
function createShopNameBySellerId(shops: AdminKuaishouIndustryShopOption[]) {
|
||||
const names = new Map<string, string>()
|
||||
for (const shop of shops) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
FormOutlined,
|
||||
LinkOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons'
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
@@ -24,10 +26,13 @@ import {
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router'
|
||||
|
||||
import {
|
||||
deleteAdminWorkProductRule,
|
||||
deleteAdminWorkProductRuleMapping,
|
||||
fetchAdminKuaishouIndustryShops,
|
||||
fetchAdminWorkCategories,
|
||||
fetchAdminWorkProductRuleMappings,
|
||||
fetchAdminWorkProductRules,
|
||||
@@ -35,15 +40,22 @@ import {
|
||||
saveAdminWorkProductRule,
|
||||
} from '@/services/admin'
|
||||
import type { CollectField, WorkProductRule, WorkProductRuleMapping } from '@/types/worker-platform'
|
||||
import type { AdminKuaishouIndustryShopOption } from '@/types/admin'
|
||||
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
|
||||
import { formatMoney } from './shared'
|
||||
import { WorkProductBindingForm, type SourcePrefill } from './WorkProductBindingForm'
|
||||
|
||||
export default function ProductRulesPanel() {
|
||||
const { message } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const [form] = Form.useForm()
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
const [editingRule, setEditingRule] = useState<WorkProductRule | null>(null)
|
||||
const [activeMappingId, setActiveMappingId] = useState<number | 'new' | null>(null)
|
||||
const [sourcePrefill, setSourcePrefill] = useState<SourcePrefill | null>(null)
|
||||
|
||||
const [keywordInput, setKeywordInput] = useState('')
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [categoryId, setCategoryId] = useState<number | undefined>()
|
||||
@@ -64,18 +76,34 @@ export default function ProductRulesPanel() {
|
||||
queryKey: ['admin-worker-platform-product-mappings'],
|
||||
queryFn: () => fetchAdminWorkProductRuleMappings(),
|
||||
})
|
||||
const ruleMappingsQuery = useQuery({
|
||||
queryKey: ['admin-worker-platform-product-mappings', 'rule', editingRule?.ruleId],
|
||||
queryFn: () =>
|
||||
fetchAdminWorkProductRuleMappings({ ruleId: editingRule?.ruleId }).then(
|
||||
(response) => response.data,
|
||||
),
|
||||
enabled: Boolean(editingRule?.ruleId),
|
||||
})
|
||||
const categoriesQuery = useQuery({
|
||||
queryKey: ['admin-worker-platform-categories'],
|
||||
queryFn: () => fetchAdminWorkCategories(),
|
||||
})
|
||||
const shopsQuery = useQuery({
|
||||
queryKey: ['admin-kuaishou-industry-shops', 'product-rules'],
|
||||
queryFn: fetchAdminKuaishouIndustryShops,
|
||||
})
|
||||
|
||||
const shops = shopsQuery.data?.data.shops || []
|
||||
const shopNameBySellerId = useMemo(() => createShopNameBySellerId(shops), [shops])
|
||||
const ruleMappings = ruleMappingsQuery.data?.items || []
|
||||
|
||||
const rules = rulesQuery.data?.data.items || []
|
||||
const mappings = mappingsQuery.data?.data.items || []
|
||||
const mappingsByRuleId = new Map<number, WorkProductRuleMapping[]>()
|
||||
for (const mapping of mappings) {
|
||||
const ruleMappings = mappingsByRuleId.get(mapping.ruleId) || []
|
||||
ruleMappings.push(mapping)
|
||||
mappingsByRuleId.set(mapping.ruleId, ruleMappings)
|
||||
const rMappings = mappingsByRuleId.get(mapping.ruleId) || []
|
||||
rMappings.push(mapping)
|
||||
mappingsByRuleId.set(mapping.ruleId, rMappings)
|
||||
}
|
||||
const rulesPagination = rulesQuery.data?.data.pagination
|
||||
const categoryCounts = new Map(
|
||||
@@ -85,18 +113,61 @@ export default function ProductRulesPanel() {
|
||||
]),
|
||||
)
|
||||
|
||||
// Default select first rule if none selected when list loads
|
||||
useEffect(() => {
|
||||
if (!editingRule && rules.length > 0) {
|
||||
editRule(rules[0])
|
||||
}
|
||||
}, [rules, editingRule])
|
||||
|
||||
// Sync activeMappingId when ruleMappings load
|
||||
useEffect(() => {
|
||||
if (ruleMappings.length > 0) {
|
||||
if (
|
||||
activeMappingId !== 'new' &&
|
||||
(!activeMappingId || !ruleMappings.some((m) => m.mappingId === activeMappingId))
|
||||
) {
|
||||
setActiveMappingId(ruleMappings[0].mappingId)
|
||||
}
|
||||
} else {
|
||||
setActiveMappingId('new')
|
||||
}
|
||||
}, [ruleMappings, activeMappingId])
|
||||
|
||||
function searchRules(value = keywordInput) {
|
||||
setKeyword(value.trim())
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
// Handle URL navigation like ?tab=rules&bind=1&sellerId=...
|
||||
useEffect(() => {
|
||||
if (!searchParams.get('bind')) return
|
||||
const prefill: SourcePrefill = {
|
||||
sellerId: String(searchParams.get('sellerId') || '').trim(),
|
||||
relItemId: String(searchParams.get('relItemId') || '').trim(),
|
||||
itemTitle: String(searchParams.get('itemTitle') || '').trim(),
|
||||
relSkuId: String(searchParams.get('relSkuId') || '').trim(),
|
||||
skuNick: String(searchParams.get('skuNick') || '').trim(),
|
||||
}
|
||||
if (!prefill.sellerId) {
|
||||
void navigate('/admin/worker-platform?tab=rules', { replace: true })
|
||||
return
|
||||
}
|
||||
setSourcePrefill(prefill)
|
||||
setActiveMappingId('new')
|
||||
void navigate('/admin/worker-platform?tab=rules', { replace: true })
|
||||
}, [navigate, searchParams])
|
||||
|
||||
function resetRuleForm() {
|
||||
setEditingRule(null)
|
||||
setActiveMappingId('new')
|
||||
setSourcePrefill(null)
|
||||
form.resetFields()
|
||||
}
|
||||
|
||||
function editRule(rule: WorkProductRule) {
|
||||
setEditingRule(rule)
|
||||
setActiveMappingId(null) // will auto-select first mapping in useEffect
|
||||
const hasManualSharingOverride = hasManualSharingInput(
|
||||
rule.sharing?.totalQuantity,
|
||||
rule.sharing?.unitReward,
|
||||
@@ -181,17 +252,21 @@ export default function ProductRulesPanel() {
|
||||
Number(values.sharingUnitReward || 0) > 0
|
||||
: hasManualSharingInput(values.sharingTotalQuantity, values.sharingUnitReward)
|
||||
const { pricingMode: _pricingMode, ...payload } = values
|
||||
await saveAdminWorkProductRule({
|
||||
const res = await saveAdminWorkProductRule({
|
||||
...payload,
|
||||
ruleId: editingRule?.ruleId,
|
||||
ruleKey: editingRule?.ruleKey || '',
|
||||
match: editingRule?.match,
|
||||
unitPrice: unitMode ? Number(values.unitPrice || 0) : 0,
|
||||
sharingAutoFromOrder:
|
||||
unitMode && values.sharingEnabled === true && !hasManualSharingOverride,
|
||||
})
|
||||
message.success(editingRule ? '接单模板已更新' : '接单模板已创建')
|
||||
resetRuleForm()
|
||||
const savedRule = res.data?.rule
|
||||
if (savedRule) {
|
||||
editRule(savedRule)
|
||||
} else {
|
||||
resetRuleForm()
|
||||
}
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['admin-worker-platform-product-rules'],
|
||||
@@ -216,11 +291,7 @@ export default function ProductRulesPanel() {
|
||||
ruleKey: rule.ruleKey,
|
||||
provider: rule.provider,
|
||||
platform: rule.platform,
|
||||
shopId: rule.shopId,
|
||||
skuCode: rule.skuCode,
|
||||
productName: rule.productName,
|
||||
matchType: rule.matchType,
|
||||
...rule.match,
|
||||
categoryId: rule.categoryId || undefined,
|
||||
rewardAmount: rule.rewardAmount / 100,
|
||||
unitPrice: rule.unitPriceFen / 100,
|
||||
@@ -268,6 +339,31 @@ export default function ProductRulesPanel() {
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteBinding(mapping: WorkProductRuleMapping) {
|
||||
try {
|
||||
await deleteAdminWorkProductRuleMapping(mapping.mappingId)
|
||||
message.success('商品绑定已删除')
|
||||
setActiveMappingId(null)
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-product-mappings'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-product-rules'] }),
|
||||
])
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '删除商品绑定失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBindingSaved(savedMappingId?: number) {
|
||||
if (savedMappingId) {
|
||||
setActiveMappingId(savedMappingId)
|
||||
}
|
||||
setSourcePrefill(null)
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-product-mappings'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-product-rules'] }),
|
||||
])
|
||||
}
|
||||
|
||||
async function reprocessKuaishouOrders() {
|
||||
try {
|
||||
const response = await reprocessAdminKuaishouWorkOrderMatches()
|
||||
@@ -307,20 +403,128 @@ export default function ProductRulesPanel() {
|
||||
})
|
||||
}
|
||||
|
||||
const columns: TableColumnsType<WorkProductRule> = [
|
||||
const bindingSummaryColumns: TableColumnsType<WorkProductRuleMapping> = [
|
||||
{
|
||||
title: '模板 / 商品',
|
||||
key: 'rule',
|
||||
width: '34%',
|
||||
title: '店铺 / 商品',
|
||||
key: 'product',
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text strong title={row.productName || row.ruleKey}>
|
||||
<Typography.Text strong ellipsis={{ tooltip: row.itemTitle || row.relItemId }}>
|
||||
{row.itemTitle || (row.relItemId ? `指定商品 ${row.relItemId}` : '全部商品')}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
店铺{' '}
|
||||
{row.sellerIds
|
||||
.map((sellerId) => formatShopLabel(sellerId, shopNameBySellerId))
|
||||
.join('、')}
|
||||
</Typography.Text>
|
||||
</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: 'SKU 条件',
|
||||
key: 'sku',
|
||||
width: 140,
|
||||
render: (_, row) =>
|
||||
row.mappingType === 'product_default' ? (
|
||||
<Typography.Text type="secondary">-</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text ellipsis={{ tooltip: row.skuNick || row.relSkuId }}>
|
||||
{row.skuNick || row.relSkuId || '-'}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '模式',
|
||||
key: 'mode',
|
||||
width: 65,
|
||||
render: (_, row) =>
|
||||
row.mappingType === 'sku_series' ? (
|
||||
<Tag style={{ margin: 0 }}>{row.skuMatchMode === 'exact' ? '精确' : '模糊'}</Tag>
|
||||
) : (
|
||||
<Typography.Text type="secondary">-</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'enabled',
|
||||
width: 65,
|
||||
render: (enabled) => (
|
||||
<Tag color={enabled ? 'green' : 'default'} style={{ margin: 0 }}>
|
||||
{enabled ? '启用' : '停用'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 85,
|
||||
render: (_, row) => (
|
||||
<Space size={0}>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setActiveMappingId(row.mappingId)
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="删除此商品绑定规则?"
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => deleteBinding(row)}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
// Columns for right table (40% width)
|
||||
const columns: TableColumnsType<WorkProductRule> = [
|
||||
{
|
||||
title: '模板 / 分类',
|
||||
key: 'rule',
|
||||
width: '42%',
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text strong title={row.productName || row.ruleKey} ellipsis>
|
||||
{row.productName || row.ruleKey}
|
||||
</Typography.Text>
|
||||
<Space size={4} wrap style={{ marginTop: 2 }}>
|
||||
<Tag color="blue" style={{ margin: 0, fontSize: 11, lineHeight: '18px' }}>
|
||||
{row.ruleKey}
|
||||
</Tag>
|
||||
{row.categoryName ? (
|
||||
<Tag color="cyan" style={{ margin: 0, fontSize: 11, lineHeight: '18px' }}>
|
||||
{row.categoryName}
|
||||
</Tag>
|
||||
) : null}
|
||||
{row.provider ? (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
|
||||
{row.provider}
|
||||
@@ -331,27 +535,14 @@ export default function ProductRulesPanel() {
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '分类',
|
||||
dataIndex: 'categoryName',
|
||||
width: '12%',
|
||||
render: (value) =>
|
||||
value ? (
|
||||
<Tag color="cyan" style={{ margin: 0 }}>
|
||||
{value}
|
||||
</Tag>
|
||||
) : (
|
||||
<Typography.Text type="secondary">-</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '已匹配商品',
|
||||
title: '已绑定商品',
|
||||
key: 'productMappings',
|
||||
width: '18%',
|
||||
width: '24%',
|
||||
render: (_, row) => {
|
||||
const ruleMappings = mappingsByRuleId.get(row.ruleId) || []
|
||||
const enabledCount = ruleMappings.filter((mapping) => mapping.enabled).length
|
||||
const disabledCount = ruleMappings.length - enabledCount
|
||||
return ruleMappings.length > 0 ? (
|
||||
const rMappings = mappingsByRuleId.get(row.ruleId) || []
|
||||
const enabledCount = rMappings.filter((m) => m.enabled).length
|
||||
const disabledCount = rMappings.length - enabledCount
|
||||
return rMappings.length > 0 ? (
|
||||
<div className="cell-stack">
|
||||
<Tag
|
||||
color={enabledCount > 0 ? 'green' : 'default'}
|
||||
@@ -373,11 +564,11 @@ export default function ProductRulesPanel() {
|
||||
{
|
||||
title: '接单金额',
|
||||
key: 'reward',
|
||||
width: '14%',
|
||||
width: '20%',
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text strong style={{ color: '#3f8600', fontSize: 13 }}>
|
||||
{row.sharing?.autoFromOrder ? '随订单自动计算' : formatMoney(row.rewardAmount)}
|
||||
<Typography.Text strong style={{ color: '#3f8600', fontSize: 12 }}>
|
||||
{row.sharing?.autoFromOrder ? '随订单计算' : formatMoney(row.rewardAmount)}
|
||||
</Typography.Text>
|
||||
{row.unitPriceFen > 0 ? (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
|
||||
@@ -387,37 +578,28 @@ export default function ProductRulesPanel() {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '匹配',
|
||||
dataIndex: 'matchType',
|
||||
width: '9%',
|
||||
render: (value) => (
|
||||
<Tag color={value === 'exact' ? 'blue' : 'orange'} style={{ margin: 0 }}>
|
||||
{value === 'exact' ? '精确' : '包含'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: '7%',
|
||||
width: '14%',
|
||||
render: (_, row) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={row.enabled}
|
||||
checkedChildren="启用"
|
||||
unCheckedChildren="停用"
|
||||
checkedChildren="开"
|
||||
unCheckedChildren="关"
|
||||
onChange={(checked) => toggleRuleEnabled(row, checked)}
|
||||
onClick={(_, e) => e.stopPropagation()}
|
||||
/>
|
||||
<Space size={2} wrap>
|
||||
{row.autoCreate ? (
|
||||
<Tag color="blue" style={{ fontSize: 10, margin: 0, padding: '0 4px' }}>
|
||||
<Tag color="blue" style={{ fontSize: 10, margin: 0, padding: '0 3px' }}>
|
||||
自动
|
||||
</Tag>
|
||||
) : null}
|
||||
{row.sharing?.enabled ? (
|
||||
<Tag color="purple" style={{ fontSize: 10, margin: 0, padding: '0 4px' }}>
|
||||
<Tag color="purple" style={{ fontSize: 10, margin: 0, padding: '0 3px' }}>
|
||||
拼单
|
||||
</Tag>
|
||||
) : null}
|
||||
@@ -428,12 +610,13 @@ export default function ProductRulesPanel() {
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: '6%',
|
||||
width: '10%',
|
||||
render: (_, row) => (
|
||||
<Space size={0} onClick={(event) => event.stopPropagation()}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
size="small"
|
||||
aria-label={`编辑规则 ${row.ruleKey}`}
|
||||
title="编辑规则"
|
||||
onClick={() => editRule(row)}
|
||||
@@ -449,6 +632,7 @@ export default function ProductRulesPanel() {
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
aria-label={`删除规则 ${row.ruleKey}`}
|
||||
title="删除规则"
|
||||
@@ -462,24 +646,28 @@ export default function ProductRulesPanel() {
|
||||
return (
|
||||
<section className="platform-panel-stack">
|
||||
<div className="product-rules-layout">
|
||||
{/* Left Form Card - 30% Width Proportional Split */}
|
||||
{/* Left Form Card - 60% Width Split */}
|
||||
<Card
|
||||
className="product-rules-form-card"
|
||||
title={
|
||||
<Space size={6}>
|
||||
<Space size={8}>
|
||||
<FormOutlined style={{ color: '#1677ff' }} />
|
||||
<span>{editingRule ? `编辑模板 · ${editingRule.ruleKey}` : '新建接单模板'}</span>
|
||||
<Typography.Text strong style={{ fontSize: 15 }}>
|
||||
{editingRule
|
||||
? `编辑模板 · ${editingRule.productName || editingRule.ruleKey}`
|
||||
: '新建接单模板'}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space size={4}>
|
||||
<Space size={8} align="center">
|
||||
{editingRule ? (
|
||||
<Button type="link" size="small" onClick={resetRuleForm}>
|
||||
取消编辑
|
||||
<Button size="middle" onClick={resetRuleForm}>
|
||||
+ 新建模板
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="primary" htmlType="submit" form="product-rule-form">
|
||||
{editingRule ? '保存修改' : '创建规则'}
|
||||
<Button type="primary" size="middle" htmlType="submit" form="product-rule-form">
|
||||
{editingRule ? '保存模板' : '创建模板'}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
@@ -489,7 +677,7 @@ export default function ProductRulesPanel() {
|
||||
id="product-rule-form"
|
||||
form={form}
|
||||
layout="vertical"
|
||||
className="worker-rule-form-30"
|
||||
className="product-rule-main-form"
|
||||
initialValues={{
|
||||
platform: 'kuaishou',
|
||||
matchType: 'contains',
|
||||
@@ -505,15 +693,8 @@ export default function ProductRulesPanel() {
|
||||
}}
|
||||
onFinish={saveRule}
|
||||
>
|
||||
<div className="rule-form-grid-2col">
|
||||
<Form.Item label="来源" name="provider">
|
||||
<Input placeholder="如 91kaquan" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="平台" name="platform">
|
||||
<Input placeholder="kuaishou" />
|
||||
</Form.Item>
|
||||
|
||||
{/* 基础信息 */}
|
||||
<div className="form-grid-3col">
|
||||
<Form.Item
|
||||
label="模板名称"
|
||||
name="productName"
|
||||
@@ -534,7 +715,10 @@ export default function ProductRulesPanel() {
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{/* Row 2: 计价方式 + 单价/金额 + 任务时限 */}
|
||||
<div className="form-grid-3col">
|
||||
<Form.Item label="计价方式" name="pricingMode">
|
||||
<Radio.Group
|
||||
optionType="button"
|
||||
@@ -551,7 +735,7 @@ export default function ProductRulesPanel() {
|
||||
{({ getFieldValue }) =>
|
||||
getFieldValue('pricingMode') === 'unit' ? (
|
||||
<Form.Item
|
||||
label="单价"
|
||||
label="按件单价"
|
||||
name="unitPrice"
|
||||
rules={[{ required: true, message: '请填写单价' }]}
|
||||
>
|
||||
@@ -565,14 +749,13 @@ export default function ProductRulesPanel() {
|
||||
}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="排序" name="sortOrder">
|
||||
<InputNumber min={0} max={9999} className="full-width" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="任务时限" name="timeoutMinutes">
|
||||
<InputNumber min={0} step={5} addonAfter="分钟" className="full-width" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{/* Row 3: 超时策略 + 来源 + 平台 */}
|
||||
<div className="form-grid-3col">
|
||||
<Form.Item label="超时策略" name="timeoutPolicy">
|
||||
<Select
|
||||
options={[
|
||||
@@ -582,128 +765,168 @@ export default function ProductRulesPanel() {
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<div className="form-switches-strip">
|
||||
<Form.Item label="启用" name="enabled" valuePropName="checked">
|
||||
<Switch size="small" />
|
||||
<Form.Item label="来源" name="provider">
|
||||
<Input placeholder="如 91kaquan" />
|
||||
</Form.Item>
|
||||
<Form.Item label="自动创建" name="autoCreate" valuePropName="checked">
|
||||
<Switch size="small" />
|
||||
</Form.Item>
|
||||
<Form.Item label="启用拼单" name="sharingEnabled" valuePropName="checked">
|
||||
<Switch size="small" />
|
||||
|
||||
<Form.Item label="平台" name="platform">
|
||||
<Input placeholder="如 kuaishou" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{({ getFieldValue }) => {
|
||||
const sharingEnabled = getFieldValue('sharingEnabled')
|
||||
const unitMode = getFieldValue('pricingMode') === 'unit'
|
||||
if (!sharingEnabled) return null
|
||||
{/* Row 4: 排序权重 + 功能开关与拼单行内参数 */}
|
||||
<div className="form-grid-3col">
|
||||
<Form.Item label="排序权重" name="sortOrder">
|
||||
<InputNumber min={0} max={9999} placeholder="0" className="full-width" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{({ getFieldValue }) => {
|
||||
const sharingEnabled = getFieldValue('sharingEnabled')
|
||||
const unitMode = getFieldValue('pricingMode') === 'unit'
|
||||
|
||||
if (unitMode) {
|
||||
return (
|
||||
<div className="sharing-box-inline">
|
||||
<Typography.Text type="secondary" className="span-2">
|
||||
默认按匹配 SKU 数量和上方单价自动生成拼单;填写以下两项后改为固定配置。
|
||||
</Typography.Text>
|
||||
<Form.Item label="拼单数量(可选)" name="sharingTotalQuantity">
|
||||
<InputNumber min={1} max={100000} addonAfter="份" className="full-width" />
|
||||
<div className="switches-inline-group span-2">
|
||||
<Form.Item label="启用模板" name="enabled" valuePropName="checked">
|
||||
<Switch size="small" />
|
||||
</Form.Item>
|
||||
<Form.Item label="拼单单价(可选)" name="sharingUnitReward">
|
||||
<InputNumber min={0.01} step={1} addonAfter="元" className="full-width" />
|
||||
<Form.Item label="自动建单" name="autoCreate" valuePropName="checked">
|
||||
<Switch size="small" />
|
||||
</Form.Item>
|
||||
<Form.Item label="启用拼单" name="sharingEnabled" valuePropName="checked">
|
||||
<Switch size="small" />
|
||||
</Form.Item>
|
||||
|
||||
{sharingEnabled ? (
|
||||
<div className="sharing-inline-inputs">
|
||||
<Form.Item name="sharingTotalQuantity" style={{ margin: 0 }}>
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={1}
|
||||
placeholder="拼单数量"
|
||||
addonAfter="份"
|
||||
style={{ width: 115 }}
|
||||
onChange={(value) =>
|
||||
!unitMode &&
|
||||
syncSharingRuleForm({ sharingTotalQuantity: Number(value) || 0 })
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="sharingUnitReward" style={{ margin: 0 }}>
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0.01}
|
||||
step={1}
|
||||
placeholder="拼单单价"
|
||||
addonAfter="元"
|
||||
style={{ width: 115 }}
|
||||
onChange={(value) =>
|
||||
!unitMode &&
|
||||
syncSharingRuleForm({ sharingUnitReward: Number(value) || 0 })
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
{!unitMode ? (
|
||||
<Form.Item name="sharingTotalAmount" style={{ margin: 0 }}>
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0.01}
|
||||
step={1}
|
||||
placeholder="拼单总价"
|
||||
addonAfter="元"
|
||||
style={{ width: 115 }}
|
||||
onChange={(value) =>
|
||||
syncSharingRuleForm({ sharingTotalAmount: Number(value) || 0 })
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}}
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<div className="sharing-box-inline">
|
||||
<Form.Item
|
||||
label="拼单数量"
|
||||
name="sharingTotalQuantity"
|
||||
rules={[{ required: true, message: '必填' }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={100000}
|
||||
addonAfter="份"
|
||||
className="full-width"
|
||||
onChange={(value) =>
|
||||
syncSharingRuleForm({ sharingTotalQuantity: Number(value) || 0 })
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="拼单单价"
|
||||
name="sharingUnitReward"
|
||||
rules={[{ required: true, message: '必填' }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={0.01}
|
||||
step={1}
|
||||
addonAfter="元"
|
||||
className="full-width"
|
||||
onChange={(value) =>
|
||||
syncSharingRuleForm({ sharingUnitReward: Number(value) || 0 })
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="拼单总价"
|
||||
name="sharingTotalAmount"
|
||||
rules={[{ required: true, message: '必填' }]}
|
||||
>
|
||||
<InputNumber
|
||||
min={0.01}
|
||||
step={1}
|
||||
addonAfter="元"
|
||||
className="full-width"
|
||||
onChange={(value) =>
|
||||
syncSharingRuleForm({ sharingTotalAmount: Number(value) || 0 })
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Form.Item>
|
||||
|
||||
{/* 资料字段: 默认展开 4 行全部清晰可见,无内嵌滚动条 */}
|
||||
{/* 资料字段 */}
|
||||
<Form.Item
|
||||
label="资料字段"
|
||||
label="资料字段(工单要求打手提交的资料)"
|
||||
name="fieldsText"
|
||||
style={{ marginBottom: 0 }}
|
||||
extra="格式:key:名称#选项1,选项2"
|
||||
extra="格式:key:名称#选项1,选项2(每行一个字段)"
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={4}
|
||||
placeholder="gameId:游戏编号 gameNickname:游戏昵称 system:系统#安卓,苹果 serverZone:区服#QQ区,微信区"
|
||||
rows={2}
|
||||
placeholder={
|
||||
'gameId:游戏编号\ngameNickname:游戏昵称\nsystem:系统#安卓,苹果\nserverZone:区服#QQ区,微信区'
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Divider style={{ margin: '14px 0 12px' }} />
|
||||
|
||||
{/* 适用商品绑定区域 */}
|
||||
<div className="binding-section-container">
|
||||
<div className="binding-section-header">
|
||||
<Space align="center" size={8}>
|
||||
<LinkOutlined style={{ color: '#1677ff' }} />
|
||||
<Typography.Text strong style={{ fontSize: 14 }}>
|
||||
适用商品绑定
|
||||
</Typography.Text>
|
||||
{editingRule ? (
|
||||
<Tag color={ruleMappings.length > 0 ? 'blue' : 'default'} style={{ margin: 0 }}>
|
||||
已绑定 {ruleMappings.length} 条规则
|
||||
</Tag>
|
||||
) : null}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{editingRule ? (
|
||||
<div className="binding-editor-wrap">
|
||||
{/* 内联编辑表单(直接展开,免弹窗) */}
|
||||
<WorkProductBindingForm
|
||||
ruleId={editingRule.ruleId}
|
||||
ruleName={editingRule.productName || editingRule.ruleKey}
|
||||
shops={shops}
|
||||
mappings={ruleMappings}
|
||||
activeMappingId={activeMappingId}
|
||||
sourcePrefill={sourcePrefill}
|
||||
onSelectMapping={(id) => setActiveMappingId(id)}
|
||||
onDeleteMapping={deleteBinding}
|
||||
onSaved={handleBindingSaved}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-rule-hint">
|
||||
<Typography.Text type="secondary">
|
||||
💡 填写并保存接单模板后,即可在此直接配置适用商品绑定与 SKU 匹配规则。
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Right Table Card - 60% Width, Zero Horizontal Scrollbar */}
|
||||
{/* Right Table Card - 40% Width */}
|
||||
<Card
|
||||
className="product-rules-list-card"
|
||||
title={
|
||||
<Space align="center" size={10}>
|
||||
<Space align="center" size={8}>
|
||||
<Typography.Text strong style={{ fontSize: 15 }}>
|
||||
接单模板列表
|
||||
</Typography.Text>
|
||||
<Tag color="blue" style={{ margin: 0 }}>
|
||||
{rulesPagination?.total || 0} 条规则
|
||||
{rulesPagination?.total || 0}
|
||||
</Tag>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space size={8} wrap>
|
||||
<Space size={6} wrap className="rules-list-card-extra">
|
||||
<Input.Search
|
||||
allowClear
|
||||
placeholder="搜索模板/商品/SKU/店铺"
|
||||
style={{ width: 200 }}
|
||||
placeholder="搜索模板/商品/SKU"
|
||||
className="search-input-fluid"
|
||||
value={keywordInput}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value
|
||||
@@ -714,14 +937,15 @@ export default function ProductRulesPanel() {
|
||||
/>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
size="middle"
|
||||
loading={rulesQuery.isFetching}
|
||||
onClick={() => rulesQuery.refetch()}
|
||||
>
|
||||
刷新
|
||||
/>
|
||||
<Button size="middle" onClick={reprocessKuaishouOrders}>
|
||||
重匹配
|
||||
</Button>
|
||||
<Button onClick={reprocessKuaishouOrders}>重匹配快手订单</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={resetRuleForm}>
|
||||
新建规则
|
||||
<Button type="primary" size="middle" icon={<PlusOutlined />} onClick={resetRuleForm}>
|
||||
新建
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
@@ -731,6 +955,7 @@ export default function ProductRulesPanel() {
|
||||
<div className="product-rules-category-tabs-bar">
|
||||
<Tabs
|
||||
activeKey={categoryId ? String(categoryId) : 'all'}
|
||||
size="small"
|
||||
onChange={(key) => {
|
||||
setCategoryId(key === 'all' ? undefined : Number(key))
|
||||
setPage(1)
|
||||
@@ -750,6 +975,7 @@ export default function ProductRulesPanel() {
|
||||
|
||||
<Table<WorkProductRule>
|
||||
rowKey="ruleId"
|
||||
size="small"
|
||||
loading={rulesQuery.isLoading || mappingsQuery.isLoading}
|
||||
dataSource={rules}
|
||||
columns={columns}
|
||||
@@ -764,7 +990,8 @@ export default function ProductRulesPanel() {
|
||||
}}
|
||||
onRow={(row) => ({
|
||||
onClick: () => editRule(row),
|
||||
title: '点击编辑此规则',
|
||||
style: { cursor: 'pointer' },
|
||||
title: '点击在左侧编辑此模板',
|
||||
})}
|
||||
pagination={buildAdminTablePagination({
|
||||
current: rulesPagination?.page || page,
|
||||
@@ -797,3 +1024,21 @@ function formatRuleFields(fields: CollectField[]) {
|
||||
function hasManualSharingInput(quantity: unknown, unitRewardFen: unknown) {
|
||||
return Number(quantity || 0) > 1 || Number(unitRewardFen || 0) > 0
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
import {
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Form,
|
||||
Input,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
CheckOutlined,
|
||||
DeleteOutlined,
|
||||
LinkOutlined,
|
||||
PlusOutlined,
|
||||
ShopOutlined,
|
||||
} from '@ant-design/icons'
|
||||
|
||||
import { saveAdminWorkProductRuleMapping } from '@/services/admin'
|
||||
import type { WorkProductRuleMapping } from '@/types/worker-platform'
|
||||
import type { AdminKuaishouIndustryShopOption } from '@/types/admin'
|
||||
|
||||
type MappingFormValues = {
|
||||
mappingId?: number
|
||||
ruleId?: number
|
||||
sellerIds: string[]
|
||||
productScope: 'all_products' | 'selected_products'
|
||||
relItemId?: string
|
||||
itemTitle?: string
|
||||
relSkuId?: string
|
||||
skuNick?: string
|
||||
skuMatchMode: 'fuzzy' | 'exact'
|
||||
mappingType: 'product_default' | 'sku_exact' | 'sku_series'
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export type SourcePrefill = {
|
||||
sellerId: string
|
||||
relItemId: string
|
||||
itemTitle: string
|
||||
relSkuId: string
|
||||
skuNick: string
|
||||
}
|
||||
|
||||
export type WorkProductBindingFormProps = {
|
||||
ruleId: number
|
||||
ruleName?: string
|
||||
shops: AdminKuaishouIndustryShopOption[]
|
||||
mappings: WorkProductRuleMapping[]
|
||||
activeMappingId?: number | 'new' | null
|
||||
sourcePrefill?: SourcePrefill | null
|
||||
onSelectMapping: (mappingId: number | 'new') => void
|
||||
onDeleteMapping?: (mapping: WorkProductRuleMapping) => void
|
||||
onSaved: (savedMappingId?: number) => void
|
||||
}
|
||||
|
||||
export function WorkProductBindingForm({
|
||||
ruleId,
|
||||
ruleName,
|
||||
shops,
|
||||
mappings,
|
||||
activeMappingId,
|
||||
sourcePrefill,
|
||||
onSelectMapping,
|
||||
onDeleteMapping,
|
||||
onSaved,
|
||||
}: WorkProductBindingFormProps) {
|
||||
const { message } = App.useApp()
|
||||
const [form] = Form.useForm<MappingFormValues>()
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const shopNameBySellerId = useMemo(() => createShopNameBySellerId(shops), [shops])
|
||||
const sellerOptions = useMemo(() => {
|
||||
const knownSellerIds = shops.map((shop) => String(shop.sellerId || '').trim()).filter(Boolean)
|
||||
const prefillSellerIds = sourcePrefill
|
||||
? [String(sourcePrefill.sellerId || '').trim()].filter(Boolean)
|
||||
: []
|
||||
const values = Array.from(new Set([...knownSellerIds, ...prefillSellerIds]))
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
.map((sellerId) => ({
|
||||
value: sellerId,
|
||||
label: formatShopLabel(sellerId, shopNameBySellerId),
|
||||
}))
|
||||
return values
|
||||
}, [shops, shopNameBySellerId, sourcePrefill])
|
||||
|
||||
const currentMapping = useMemo(() => {
|
||||
if (activeMappingId === 'new') return null
|
||||
if (typeof activeMappingId === 'number') {
|
||||
return mappings.find((m) => m.mappingId === activeMappingId) || null
|
||||
}
|
||||
return mappings[0] || null
|
||||
}, [activeMappingId, mappings])
|
||||
|
||||
const isNew = activeMappingId === 'new' || (!currentMapping && mappings.length === 0)
|
||||
|
||||
useEffect(() => {
|
||||
if (currentMapping) {
|
||||
form.setFieldsValue({
|
||||
mappingId: currentMapping.mappingId,
|
||||
ruleId: currentMapping.ruleId,
|
||||
sellerIds: currentMapping.sellerIds,
|
||||
productScope:
|
||||
currentMapping.relItemId || currentMapping.itemTitle
|
||||
? 'selected_products'
|
||||
: 'all_products',
|
||||
relItemId: currentMapping.relItemId,
|
||||
itemTitle: currentMapping.itemTitle,
|
||||
relSkuId: currentMapping.relSkuId,
|
||||
skuNick: currentMapping.skuNick,
|
||||
skuMatchMode: currentMapping.skuMatchMode,
|
||||
mappingType: currentMapping.mappingType,
|
||||
enabled: currentMapping.enabled,
|
||||
})
|
||||
return
|
||||
}
|
||||
form.resetFields()
|
||||
form.setFieldsValue({
|
||||
ruleId,
|
||||
mappingType: 'product_default',
|
||||
productScope: sourcePrefill?.relItemId ? 'selected_products' : 'all_products',
|
||||
skuMatchMode: 'fuzzy',
|
||||
enabled: true,
|
||||
sellerIds: sourcePrefill?.sellerId
|
||||
? [sourcePrefill.sellerId].filter(Boolean)
|
||||
: sellerOptions.map((opt) => opt.value),
|
||||
relItemId: sourcePrefill?.relItemId || '',
|
||||
itemTitle: sourcePrefill?.itemTitle || '',
|
||||
relSkuId: sourcePrefill?.relSkuId || '',
|
||||
skuNick: sourcePrefill?.skuNick || '',
|
||||
})
|
||||
}, [currentMapping, form, isNew, ruleId, sellerOptions, sourcePrefill])
|
||||
|
||||
function handleValuesChange(changedValues: Partial<MappingFormValues>) {
|
||||
if (changedValues.mappingType) {
|
||||
const isProductDefault = changedValues.mappingType === 'product_default'
|
||||
form.setFieldsValue({
|
||||
productScope: isProductDefault ? 'selected_products' : 'all_products',
|
||||
...(isProductDefault ? {} : { relItemId: '', itemTitle: '' }),
|
||||
})
|
||||
}
|
||||
if (changedValues.productScope === 'all_products') {
|
||||
form.setFieldsValue({ relItemId: '', itemTitle: '' })
|
||||
}
|
||||
if (
|
||||
changedValues.sellerIds &&
|
||||
changedValues.sellerIds.length > 1 &&
|
||||
form.getFieldValue('mappingType') === 'product_default'
|
||||
) {
|
||||
form.setFieldValue('relItemId', '')
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
try {
|
||||
const values = await form.validateFields()
|
||||
const { productScope: _productScope, ...payload } = values
|
||||
const targetRuleId = ruleId || payload.ruleId
|
||||
if (!targetRuleId) {
|
||||
message.error('请选择接单模板')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
const res = await saveAdminWorkProductRuleMapping({ ...payload, ruleId: targetRuleId })
|
||||
message.success(values.mappingId ? '商品绑定已更新' : '商品绑定已创建')
|
||||
onSaved(res.data?.mapping?.mappingId)
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && 'errorFields' in error) return
|
||||
message.error(error instanceof Error ? error.message : '保存商品绑定失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
className="binding-editor-card"
|
||||
title={
|
||||
<div className="binding-editor-card-header">
|
||||
<Space size={8} wrap className="binding-pills-row">
|
||||
<LinkOutlined style={{ color: '#1677ff' }} />
|
||||
<Typography.Text strong style={{ fontSize: 13 }}>
|
||||
商品匹配规则:
|
||||
</Typography.Text>
|
||||
{mappings.map((m, index) => {
|
||||
const isSelected =
|
||||
!isNew &&
|
||||
(currentMapping?.mappingId === m.mappingId || (index === 0 && !currentMapping))
|
||||
const label =
|
||||
m.mappingType === 'product_default'
|
||||
? m.itemTitle || m.relItemId
|
||||
? '指定商品'
|
||||
: '全部商品 (默认)'
|
||||
: m.skuNick ||
|
||||
m.relSkuId ||
|
||||
(m.mappingType === 'sku_series' ? 'SKU数量系列' : 'SKU精确覆盖')
|
||||
return (
|
||||
<Tag.CheckableTag
|
||||
key={m.mappingId}
|
||||
checked={isSelected}
|
||||
onChange={() => onSelectMapping(m.mappingId)}
|
||||
className={`binding-pill-tag ${isSelected ? 'active' : ''}`}
|
||||
>
|
||||
#{index + 1} {label}
|
||||
</Tag.CheckableTag>
|
||||
)
|
||||
})}
|
||||
<Button
|
||||
size="small"
|
||||
type={isNew ? 'primary' : 'dashed'}
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => onSelectMapping('new')}
|
||||
className="binding-add-btn"
|
||||
>
|
||||
添加新规则
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
extra={
|
||||
<Space size={8}>
|
||||
{currentMapping && !isNew && onDeleteMapping ? (
|
||||
<Popconfirm
|
||||
title="确定删除此商品绑定规则?"
|
||||
description="删除后,快手订单将不再按此规则匹配到当前模板。"
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => onDeleteMapping(currentMapping)}
|
||||
>
|
||||
<Button size="small" type="text" danger icon={<DeleteOutlined />}>
|
||||
删除此规则
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<CheckOutlined />}
|
||||
loading={saving}
|
||||
onClick={submit}
|
||||
>
|
||||
{isNew ? '创建商品绑定' : '保存商品绑定'}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onValuesChange={handleValuesChange}
|
||||
className="binding-form-content"
|
||||
>
|
||||
<Form.Item name="mappingId" hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="ruleId" hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
{/* 覆盖店铺 */}
|
||||
<Form.Item
|
||||
label={
|
||||
<Space size={4}>
|
||||
<ShopOutlined />
|
||||
<span>覆盖店铺</span>
|
||||
</Space>
|
||||
}
|
||||
name="sellerIds"
|
||||
rules={[{ required: true, message: '至少选择一个店铺' }]}
|
||||
>
|
||||
<ShopSelector options={sellerOptions} />
|
||||
</Form.Item>
|
||||
|
||||
{/* 绑定类型 & 商品范围 & 状态 */}
|
||||
<div className="form-grid-3col">
|
||||
<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 noStyle shouldUpdate>
|
||||
{({ getFieldValue }) => {
|
||||
const mappingType = getFieldValue('mappingType')
|
||||
const isProductDefault = mappingType === 'product_default'
|
||||
return !isProductDefault ? (
|
||||
<Form.Item label="商品范围" name="productScope">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'all_products', label: '全部商品' },
|
||||
{ value: 'selected_products', label: '指定商品' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item label="快手大标题" name="itemTitle">
|
||||
<Input placeholder="商品大标题或核心关键词" />
|
||||
</Form.Item>
|
||||
)
|
||||
}}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="规则状态" name="enabled" valuePropName="checked">
|
||||
<Switch checkedChildren="已启用" unCheckedChildren="已停用" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{/* 商品ID / 大标题 / SKU 条件 */}
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{({ getFieldValue }) => {
|
||||
const mappingType = getFieldValue('mappingType')
|
||||
const isProductDefault = mappingType === 'product_default'
|
||||
const productScope = getFieldValue('productScope')
|
||||
const showProductScope = isProductDefault || productScope === 'selected_products'
|
||||
|
||||
return (
|
||||
<div className="form-grid-3col">
|
||||
{showProductScope ? (
|
||||
<Form.Item
|
||||
label="关联商品 ID"
|
||||
name="relItemId"
|
||||
extra="支持多个商品ID,逗号/空格分隔"
|
||||
>
|
||||
<Input placeholder="如 123456789, 987654321" />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
|
||||
{!isProductDefault && showProductScope ? (
|
||||
<Form.Item label="快手大标题" name="itemTitle" extra="可选,父商品分流">
|
||||
<Input placeholder="可选,商品大标题关键字" />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
|
||||
{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>
|
||||
</>
|
||||
) : mappingType === 'sku_series' ? (
|
||||
<>
|
||||
<Form.Item label="SKU 系列名称" name="skuNick">
|
||||
<Input placeholder="例如:指挥官密钥;自动覆盖 1个、10个等规格" />
|
||||
</Form.Item>
|
||||
<Form.Item label="SKU 匹配方式" name="skuMatchMode">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'fuzzy', label: '模糊匹配(兼容密钥/秘钥)' },
|
||||
{ value: 'exact', label: '精确匹配' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function ShopSelector({
|
||||
value = [],
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
value?: string[]
|
||||
onChange?: (nextValue: string[]) => void
|
||||
options: Array<{ value: string; label: string }>
|
||||
}) {
|
||||
const optionValues = options.map((option) => option.value)
|
||||
const selectedValues = Array.isArray(value) ? value.map(String) : []
|
||||
const selectedOptionValues = selectedValues.filter((sellerId) => optionValues.includes(sellerId))
|
||||
const otherValues = selectedValues.filter((sellerId) => !optionValues.includes(sellerId))
|
||||
const allSelected = optionValues.length > 0 && selectedOptionValues.length === optionValues.length
|
||||
const partiallySelected = selectedOptionValues.length > 0 && !allSelected
|
||||
|
||||
return (
|
||||
<div className="shop-selector-box-inline">
|
||||
<Checkbox
|
||||
checked={allSelected}
|
||||
disabled={optionValues.length === 0}
|
||||
indeterminate={partiallySelected}
|
||||
onChange={(event) =>
|
||||
onChange?.(event.target.checked ? [...otherValues, ...optionValues] : otherValues)
|
||||
}
|
||||
className="shop-all-toggle"
|
||||
>
|
||||
全选
|
||||
</Checkbox>
|
||||
<Checkbox.Group
|
||||
value={selectedValues}
|
||||
onChange={(nextValues) => onChange?.(nextValues.map(String))}
|
||||
className="shop-checkbox-inline-wrap"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<Checkbox key={option.value} value={option.value} className="shop-chk-item">
|
||||
{option.label}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Checkbox.Group>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user