feat(admin): 重构接单模板与商品绑定内联编辑 UI 布局
- 优化接单模板与模板列表 6:4 比例自适应布局 - 适用商品绑定支持左侧直接内联展开编辑,免弹窗模式 - 支持绑定规则多标签快速切换、实时新增与删除 - 基础表单与商品绑定统一采用 3 列网格布局,严格几何对齐 - 「启用拼单」开关及参数行内紧凑展开,移除多余背景框与纵向滚动条
This commit is contained in:
@@ -14,6 +14,7 @@ export async function listWorkProductRuleMappings(
|
||||
input: {
|
||||
enabled?: boolean | null
|
||||
sellerId?: string
|
||||
ruleId?: number | string | null
|
||||
} = {},
|
||||
): Promise<WorkProductRuleMappingRow[]> {
|
||||
const conditions: string[] = []
|
||||
@@ -29,6 +30,11 @@ export async function listWorkProductRuleMappings(
|
||||
params.push(sellerId)
|
||||
conditions.push(`wprm.seller_ids_json ? $${params.length}`)
|
||||
}
|
||||
const ruleId = Number(input.ruleId)
|
||||
if (Number.isInteger(ruleId) && ruleId > 0) {
|
||||
params.push(ruleId)
|
||||
conditions.push(`wprm.rule_id = $${params.length}`)
|
||||
}
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||
const result = await query<WorkProductRuleMappingRow>(
|
||||
`${MAPPING_SELECT}
|
||||
@@ -41,6 +47,61 @@ export async function listWorkProductRuleMappings(
|
||||
return result.rows
|
||||
}
|
||||
|
||||
export async function listWorkProductRuleMappingsPage(
|
||||
input: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
enabled?: boolean | null
|
||||
keyword?: string
|
||||
ruleId?: number | string | null
|
||||
} = {},
|
||||
): Promise<{ items: WorkProductRuleMappingRow[]; total: number }> {
|
||||
const page = Math.max(1, Math.floor(Number(input.page) || 1))
|
||||
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(input.pageSize) || 20)))
|
||||
const conditions: string[] = []
|
||||
const params: unknown[] = []
|
||||
if (input.enabled !== undefined && input.enabled !== null) {
|
||||
params.push(input.enabled)
|
||||
conditions.push(`wprm.enabled = $${params.length}`)
|
||||
}
|
||||
const ruleId = Number(input.ruleId)
|
||||
if (Number.isInteger(ruleId) && ruleId > 0) {
|
||||
params.push(ruleId)
|
||||
conditions.push(`wprm.rule_id = $${params.length}`)
|
||||
}
|
||||
const keyword = String(input.keyword || '').trim()
|
||||
if (keyword) {
|
||||
params.push(`%${keyword}%`)
|
||||
const parameter = `$${params.length}`
|
||||
conditions.push(`(
|
||||
wpr.rule_key ILIKE ${parameter}
|
||||
OR wpr.product_name ILIKE ${parameter}
|
||||
OR wprm.item_title ILIKE ${parameter}
|
||||
OR wprm.rel_item_id ILIKE ${parameter}
|
||||
OR wprm.rel_sku_id ILIKE ${parameter}
|
||||
OR wprm.sku_nick ILIKE ${parameter}
|
||||
)`)
|
||||
}
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||
const totalResult = await query<{ total: number }>(
|
||||
`SELECT COUNT(*)::int AS total FROM work_product_rule_mappings wprm
|
||||
INNER JOIN work_product_rules wpr ON wpr.id = wprm.rule_id ${where}`,
|
||||
params,
|
||||
)
|
||||
const offset = (page - 1) * pageSize
|
||||
const listParams = [...params, pageSize, offset]
|
||||
const result = await query<WorkProductRuleMappingRow>(
|
||||
`${MAPPING_SELECT}
|
||||
${where}
|
||||
ORDER BY wprm.seller_ids_json::text ASC, wprm.rel_item_id ASC, wprm.item_title ASC,
|
||||
CASE WHEN wprm.mapping_type = 'product_default' THEN 0 ELSE 1 END ASC,
|
||||
wprm.id ASC
|
||||
LIMIT $${listParams.length - 1} OFFSET $${listParams.length}`,
|
||||
listParams,
|
||||
)
|
||||
return { items: result.rows, total: Number(totalResult.rows[0]?.total || 0) }
|
||||
}
|
||||
|
||||
export async function upsertWorkProductRuleMapping(input: {
|
||||
mappingId?: number | null
|
||||
ruleId: number
|
||||
|
||||
@@ -195,7 +195,7 @@ router.delete(
|
||||
router.get(
|
||||
'/worker-platform/product-mappings',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
createJsonHandler(() => listAdminWorkProductRuleMappings(), {
|
||||
createJsonHandler((req) => listAdminWorkProductRuleMappings(req.query), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取商品映射失败',
|
||||
scope: '[admin/worker-platform/product-mappings]',
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
listWorkOrderEventsByOrderId,
|
||||
listWorkProductRules,
|
||||
listWorkProductRuleMappings,
|
||||
listWorkProductRuleMappingsPage,
|
||||
listWorkProductMatchLogs,
|
||||
listWorkProductRulesPage,
|
||||
listWorkerLevels,
|
||||
@@ -283,8 +284,25 @@ export async function reprocessAdminKuaishouSendCodeWorkOrders(payload: JsonObje
|
||||
return reprocessKuaishouSendCodeWorkOrders(limit)
|
||||
}
|
||||
|
||||
export async function listAdminWorkProductRuleMappings() {
|
||||
const items = await listWorkProductRuleMappings()
|
||||
export async function listAdminWorkProductRuleMappings(query: JsonObject = {}) {
|
||||
const ruleId = normalizeOptionalId(query.ruleId ?? query.rule_id) || 0
|
||||
const hasPagination = query.page !== undefined || query.pageSize !== undefined || query.keyword
|
||||
if (hasPagination) {
|
||||
const page = normalizePage(query.page)
|
||||
const pageSize = normalizePageSize(query.pageSize)
|
||||
const keyword = String(query.keyword || '').trim()
|
||||
const result = await listWorkProductRuleMappingsPage({
|
||||
page,
|
||||
pageSize,
|
||||
keyword,
|
||||
ruleId,
|
||||
})
|
||||
return {
|
||||
items: result.items.map(mapWorkProductRuleMapping),
|
||||
pagination: { page, pageSize, total: result.total },
|
||||
}
|
||||
}
|
||||
const items = await listWorkProductRuleMappings({ ruleId })
|
||||
return { items: items.map(mapWorkProductRuleMapping) }
|
||||
}
|
||||
|
||||
@@ -359,8 +377,20 @@ export async function saveAdminWorkProductRuleMapping(payload: JsonObject = {})
|
||||
errorCode: 'work_product_mapping_rule_not_found',
|
||||
})
|
||||
}
|
||||
const mappingId = normalizeOptionalId(payload.mappingId ?? payload.mapping_id)
|
||||
if (normalizeBoolean(payload.enabled, true)) {
|
||||
await assertNoConflictingWorkProductRuleMapping({
|
||||
mappingId,
|
||||
sellerIds,
|
||||
relItemId,
|
||||
itemTitle,
|
||||
relSkuId,
|
||||
skuNick,
|
||||
mappingType,
|
||||
})
|
||||
}
|
||||
const mapping = await upsertWorkProductRuleMapping({
|
||||
mappingId: normalizeOptionalId(payload.mappingId ?? payload.mapping_id),
|
||||
mappingId,
|
||||
ruleId,
|
||||
sellerIds,
|
||||
relItemId,
|
||||
@@ -547,6 +577,10 @@ function normalizeKuaishouMatchId(value: unknown) {
|
||||
return normalized === '0' ? '' : normalized
|
||||
}
|
||||
|
||||
function hasOwnPayload(payload: JsonObject, key: string) {
|
||||
return Object.prototype.hasOwnProperty.call(payload, key)
|
||||
}
|
||||
|
||||
function normalizeKuaishouMatchIds(value: unknown) {
|
||||
const values = Array.isArray(value)
|
||||
? value
|
||||
@@ -580,6 +614,92 @@ function normalizeMatchTextForAdmin(value: unknown) {
|
||||
.trim()
|
||||
}
|
||||
|
||||
function assertNoConflictingWorkProductRuleMapping(input: {
|
||||
mappingId: number | null
|
||||
sellerIds: string[]
|
||||
relItemId: string
|
||||
itemTitle: string
|
||||
relSkuId: string
|
||||
skuNick: string
|
||||
mappingType: 'product_default' | 'sku_exact' | 'sku_series'
|
||||
}) {
|
||||
return listWorkProductRuleMappings({ enabled: true }).then((mappings) => {
|
||||
const conflict = mappings.find((mapping) => {
|
||||
if (input.mappingId && Number(mapping.id) === input.mappingId) return false
|
||||
const existingType =
|
||||
mapping.mapping_type === 'sku_series'
|
||||
? 'sku_series'
|
||||
: mapping.mapping_type === 'sku_exact' || mapping.mapping_type === 'sku_override'
|
||||
? 'sku_exact'
|
||||
: 'product_default'
|
||||
if (existingType !== input.mappingType) return false
|
||||
const existingSellerIds = parseKuaishouMappingSellerIds(mapping.seller_ids_json)
|
||||
if (!existingSellerIds.some((sellerId) => input.sellerIds.includes(sellerId))) return false
|
||||
|
||||
const inputItemIds = normalizeKuaishouMatchIdList(input.relItemId)
|
||||
const existingItemIds = normalizeKuaishouMatchIdList(mapping.rel_item_id)
|
||||
if (
|
||||
!mappingProductScopesOverlap(
|
||||
inputItemIds,
|
||||
input.itemTitle,
|
||||
existingItemIds,
|
||||
mapping.item_title,
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (input.mappingType === 'product_default') return true
|
||||
if (input.mappingType === 'sku_series') {
|
||||
// 精确系列是模糊系列的子集,两者并存仍可能产生同分冲突,因此统一拦截。
|
||||
return (
|
||||
normalizeMatchTextForAdmin(input.skuNick) === normalizeMatchTextForAdmin(mapping.sku_nick)
|
||||
)
|
||||
}
|
||||
|
||||
const inputSkuId = normalizeKuaishouMatchId(input.relSkuId)
|
||||
const existingSkuId = normalizeKuaishouMatchId(mapping.rel_sku_id)
|
||||
const skuIdConflict = Boolean(inputSkuId && existingSkuId && inputSkuId === existingSkuId)
|
||||
const skuNameConflict =
|
||||
Boolean(input.skuNick && mapping.sku_nick) &&
|
||||
normalizeMatchTextForAdmin(input.skuNick) === normalizeMatchTextForAdmin(mapping.sku_nick)
|
||||
return skuIdConflict || skuNameConflict
|
||||
})
|
||||
|
||||
if (conflict) {
|
||||
throw createHttpError('商品映射与已有启用映射冲突,请停用或调整其中一条', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_product_mapping_conflict',
|
||||
context: {
|
||||
mappingId: Number(conflict.id),
|
||||
ruleKey: String(conflict.rule_key || ''),
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function mappingProductScopesOverlap(
|
||||
leftItemIds: string[],
|
||||
leftTitle: string,
|
||||
rightItemIds: string[],
|
||||
rightTitle: string,
|
||||
) {
|
||||
if (leftItemIds.length > 0 && rightItemIds.length > 0) {
|
||||
return leftItemIds.some((itemId) => rightItemIds.includes(itemId))
|
||||
}
|
||||
// 只有一侧指定商品 ID 时,另一侧可能是全商品范围,按可能重叠处理并拦截。
|
||||
if (leftItemIds.length > 0 || rightItemIds.length > 0) return true
|
||||
const normalizedLeftTitle = normalizeMatchTextForAdmin(leftTitle)
|
||||
const normalizedRightTitle = normalizeMatchTextForAdmin(rightTitle)
|
||||
if (normalizedLeftTitle || normalizedRightTitle) {
|
||||
return Boolean(
|
||||
normalizedLeftTitle && normalizedRightTitle && normalizedLeftTitle === normalizedRightTitle,
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function parseJsonArray(value: unknown): unknown[] {
|
||||
if (Array.isArray(value)) return value
|
||||
try {
|
||||
@@ -592,7 +712,38 @@ function parseJsonArray(value: unknown): unknown[] {
|
||||
|
||||
export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
|
||||
const defaults = await ensureWorkerPlatformDefaults()
|
||||
const match = normalizeWorkProductRuleMatch(payload)
|
||||
const ruleId = normalizeOptionalId(payload.ruleId ?? payload.rule_id)
|
||||
const existingRule = ruleId ? await getWorkProductRuleById(ruleId) : null
|
||||
if (ruleId && !existingRule) {
|
||||
throw createHttpError('接单模板不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'work_product_rule_not_found',
|
||||
})
|
||||
}
|
||||
const hasLegacyMatchPayload = [
|
||||
'match',
|
||||
'sellerId',
|
||||
'seller_id',
|
||||
'itemId',
|
||||
'item_id',
|
||||
'relItemId',
|
||||
'rel_item_id',
|
||||
'skuId',
|
||||
'sku_id',
|
||||
'relSkuId',
|
||||
'rel_sku_id',
|
||||
'itemTitle',
|
||||
'item_title',
|
||||
'skuNick',
|
||||
'sku_nick',
|
||||
'itemTitleMatchType',
|
||||
'item_title_match_type',
|
||||
'skuNickMatchType',
|
||||
'sku_nick_match_type',
|
||||
].some((key) => Object.prototype.hasOwnProperty.call(payload, key))
|
||||
const match = hasLegacyMatchPayload
|
||||
? normalizeWorkProductRuleMatch(payload)
|
||||
: safeParseJson(existingRule?.match_json)
|
||||
const productName = String(
|
||||
payload.productName || payload.product_name || payload.skuName || '',
|
||||
).trim()
|
||||
@@ -605,15 +756,6 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
|
||||
})
|
||||
}
|
||||
|
||||
const ruleId = normalizeOptionalId(payload.ruleId ?? payload.rule_id)
|
||||
const existingRule = ruleId ? await getWorkProductRuleById(ruleId) : null
|
||||
if (ruleId && !existingRule) {
|
||||
throw createHttpError('接单模板不存在', {
|
||||
statusCode: 404,
|
||||
errorCode: 'work_product_rule_not_found',
|
||||
})
|
||||
}
|
||||
|
||||
const rewardAmount = normalizeAmountFen(payload.rewardAmount ?? payload.rewardAmountYuan, 0)
|
||||
const unitPriceFen =
|
||||
payload.unitPrice === undefined && payload.unitPriceYuan === undefined
|
||||
@@ -678,12 +820,25 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
|
||||
const ruleKey = existingRule?.rule_key || randomId('rule-')
|
||||
const rule = await upsertWorkProductRule({
|
||||
ruleKey,
|
||||
provider: String(payload.provider || '').trim(),
|
||||
platform: String(payload.platform || '').trim(),
|
||||
shopId: String(payload.shopId || payload.shop_id || '').trim(),
|
||||
skuCode,
|
||||
provider: hasOwnPayload(payload, 'provider')
|
||||
? String(payload.provider || '').trim()
|
||||
: String(existingRule?.provider || '').trim(),
|
||||
platform: hasOwnPayload(payload, 'platform')
|
||||
? String(payload.platform || '').trim()
|
||||
: String(existingRule?.platform || '').trim(),
|
||||
shopId:
|
||||
hasOwnPayload(payload, 'shopId') || hasOwnPayload(payload, 'shop_id')
|
||||
? String(payload.shopId || payload.shop_id || '').trim()
|
||||
: String(existingRule?.shop_id || '').trim(),
|
||||
skuCode:
|
||||
hasOwnPayload(payload, 'skuCode') || hasOwnPayload(payload, 'sku_code')
|
||||
? skuCode
|
||||
: String(existingRule?.sku_code || '').trim(),
|
||||
productName: productName || matchedProductName,
|
||||
matchType: normalizeMatchType(payload.matchType || payload.match_type),
|
||||
matchType:
|
||||
hasOwnPayload(payload, 'matchType') || hasOwnPayload(payload, 'match_type')
|
||||
? normalizeMatchType(payload.matchType || payload.match_type)
|
||||
: normalizeMatchType(existingRule?.match_type),
|
||||
categoryId:
|
||||
normalizeOptionalId(payload.categoryId || payload.category_id) ||
|
||||
defaults.category?.id ||
|
||||
|
||||
@@ -738,9 +738,9 @@ function scoreWorkProductRuleMapping(
|
||||
)
|
||||
const hasProductScope = Boolean(mappedItemIds.length > 0 || mappedTitle)
|
||||
if (mappingType === 'product_default' || hasProductScope) {
|
||||
// 有商品 ID 时只能按 ID 命中,避免同店同标题的不同商品串单;缺失 ID 才按标题回退。
|
||||
// 配置了商品 ID 时必须要求订单也提供商品 ID,避免缺少 ID 时误落入同店其他商品。
|
||||
if (
|
||||
(mappedItemIds.length > 0 && hasContextItemId && !itemIdMatched) ||
|
||||
(mappedItemIds.length > 0 && (!hasContextItemId || !itemIdMatched)) ||
|
||||
(mappedItemIds.length === 0 && !titleMatched)
|
||||
) {
|
||||
return null
|
||||
|
||||
@@ -351,6 +351,31 @@ test('商品默认映射支持用逗号填写多个关联商品 ID', () => {
|
||||
assert.equal(decision.rule?.id, rule.id)
|
||||
})
|
||||
|
||||
test('商品默认映射配置商品 ID 时,订单缺少商品 ID 不应误命中', () => {
|
||||
const item = buildOrderItemRow()
|
||||
item.item_snapshot_json = {
|
||||
kuaishouSendCode: {
|
||||
...item.item_snapshot_json.kuaishouSendCode,
|
||||
relItemId: '',
|
||||
},
|
||||
}
|
||||
const rule = buildProductRule({ product_name: '' })
|
||||
const decision = resolveMatchingProductRuleDecision(
|
||||
buildOrderRow(),
|
||||
item,
|
||||
[rule],
|
||||
[
|
||||
buildProductMapping({
|
||||
rel_item_id: '26765374805642',
|
||||
item_title: '',
|
||||
}),
|
||||
],
|
||||
)
|
||||
|
||||
assert.equal(decision.reason, 'unmatched')
|
||||
assert.equal(decision.rule, null)
|
||||
})
|
||||
|
||||
test('SKU 数量系列不会按公共词误匹配其他系列', () => {
|
||||
const item = buildOrderItemRow()
|
||||
item.sku_name = '指挥官隐藏款1个'
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function AdminWorkerPlatformPage() {
|
||||
: [
|
||||
{ key: 'orders', label: '接单工单', children: <WorkOrdersPanel /> },
|
||||
{ key: 'rules', label: '接单模板', children: <ProductRulesPanel /> },
|
||||
{ key: 'product-match', label: '商品匹配', children: <ProductMatchPanel /> },
|
||||
{ key: 'product-match', label: '匹配诊断', children: <ProductMatchPanel /> },
|
||||
{ key: 'categories', label: '分类', children: <CategoriesPanel /> },
|
||||
{ key: 'workers', label: '打手', children: <WorkersPanel /> },
|
||||
{ key: 'finance', label: '资金', children: <FinancePanel /> },
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -140,10 +140,11 @@ 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 fetchAdminWorkProductRuleMappings(params?: Record<string, unknown>) {
|
||||
return apiGet<{
|
||||
items: WorkProductRuleMapping[]
|
||||
pagination?: { page: number; pageSize: number; total: number }
|
||||
}>('/api/v1/admin/worker-platform/product-mappings', params)
|
||||
}
|
||||
|
||||
export function saveAdminWorkProductRuleMapping(payload: {
|
||||
|
||||
@@ -1052,120 +1052,257 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ===== 物品规则配置:3:7 比例响应式双栏 ===== */
|
||||
/* ===== 物品规则配置:6:4 比例清晰现代化双栏 ===== */
|
||||
|
||||
.product-rules-layout {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 6fr) minmax(0, 4fr);
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
align-items: start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.product-rules-form-card {
|
||||
flex: 0 0 calc(30% - 8px);
|
||||
width: calc(30% - 8px);
|
||||
min-width: 360px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border-radius: 8px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
align-self: flex-start;
|
||||
max-height: calc(100vh - 84px);
|
||||
overflow-y: auto;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.product-rules-form-card::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
.product-rules-form-card::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.product-rules-form-card::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
.product-rules-form-card .ant-card-head {
|
||||
padding: 0 16px;
|
||||
min-height: 48px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.product-rules-form-card .ant-card-body {
|
||||
padding: 12px 14px;
|
||||
overflow: visible;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.worker-rule-form {
|
||||
width: 100%;
|
||||
.product-rule-main-form .ant-form-item {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.worker-rule-form-30 .rule-form-grid-2col {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0 10px;
|
||||
}
|
||||
|
||||
.worker-rule-form-30 .rule-form-grid-2col .span-2 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.worker-rule-form-30 .ant-form-item {
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.worker-rule-form-30 .ant-form-item-label {
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
|
||||
.worker-rule-form-30 .ant-form-item-label > label {
|
||||
font-size: 12px;
|
||||
.product-rule-main-form .ant-form-item-label > label {
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.worker-rule-form-30 .form-switches-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
background: #f9fafb;
|
||||
padding: 5px 10px;
|
||||
border-radius: 6px;
|
||||
margin-top: 3px;
|
||||
margin-bottom: 7px;
|
||||
.form-grid-2col {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 14px;
|
||||
}
|
||||
|
||||
.worker-rule-form-30 .form-switches-strip .ant-form-item {
|
||||
.form-grid-3col {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0 14px;
|
||||
}
|
||||
|
||||
.form-grid-4col {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0 14px;
|
||||
}
|
||||
|
||||
.form-grid-5col {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 1fr 0.8fr 1fr 1.2fr;
|
||||
gap: 0 12px;
|
||||
}
|
||||
|
||||
.span-2 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.span-3 {
|
||||
grid-column: span 3;
|
||||
}
|
||||
|
||||
.span-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.switches-inline-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
align-self: end;
|
||||
height: 32px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.switches-inline-group .ant-form-item {
|
||||
margin-bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.worker-rule-form-30 .sharing-box-inline {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
.sharing-inline-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #f8fafc;
|
||||
border: 1px dashed #cbd5e1;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 7px;
|
||||
padding-left: 12px;
|
||||
border-left: 1px solid #cbd5e1;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.worker-rule-form-30 .sharing-box-inline .ant-form-item {
|
||||
.sharing-box-panel {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||
gap: 10px 14px;
|
||||
background: #eff6ff;
|
||||
border: 1px dashed #bfdbfe;
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.sharing-box-panel .ant-form-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* 适用商品绑定区域 */
|
||||
.binding-section-container {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.binding-section-header {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.binding-editor-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.binding-editor-card {
|
||||
background: #f8fafc !important;
|
||||
border: 1px solid #e2e8f0 !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
.binding-editor-card .ant-card-head {
|
||||
background: #f1f5f9;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding: 6px 12px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.binding-editor-card .ant-card-body {
|
||||
padding: 14px 12px 10px;
|
||||
}
|
||||
|
||||
.binding-editor-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.binding-pills-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.binding-pill-tag {
|
||||
font-size: 12px;
|
||||
line-height: 22px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #cbd5e1 !important;
|
||||
background: #fff !important;
|
||||
color: #475569 !important;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.binding-pill-tag.active {
|
||||
background: #1677ff !important;
|
||||
color: #fff !important;
|
||||
border-color: #1677ff !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.binding-add-btn {
|
||||
font-size: 12px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.binding-form-content .ant-form-item {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.binding-form-content .ant-form-item-label > label {
|
||||
font-size: 12px;
|
||||
color: #334155;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.shop-selector-box-inline {
|
||||
background: #fff;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
padding: 4px 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.shop-selector-box-inline .shop-all-toggle {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
border-right: 1px solid #e2e8f0;
|
||||
padding-right: 10px;
|
||||
margin-right: 2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.shop-selector-box-inline .shop-checkbox-inline-wrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 14px;
|
||||
}
|
||||
|
||||
.shop-selector-box-inline .shop-chk-item {
|
||||
font-size: 12px;
|
||||
margin-inline-start: 0 !important;
|
||||
}
|
||||
|
||||
.empty-rule-hint {
|
||||
background: #f8fafc;
|
||||
border: 1px dashed #cbd5e1;
|
||||
border-radius: 6px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.product-binding-row-active > td {
|
||||
background: #e6f4ff !important;
|
||||
}
|
||||
|
||||
/* 右侧模板列表卡片 */
|
||||
.product-rules-list-card {
|
||||
flex: 0 0 calc(70% - 8px);
|
||||
width: calc(70% - 8px);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.product-rules-list-card .ant-card-head {
|
||||
padding: 0 16px;
|
||||
min-height: 48px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.product-rules-list-card .ant-card-body {
|
||||
padding: 12px 16px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.product-rules-category-tabs-bar {
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 8px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
@@ -1181,20 +1318,26 @@
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.rules-list-card-extra {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rules-list-card-extra .search-input-fluid {
|
||||
min-width: 120px;
|
||||
max-width: 220px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ===== 后台响应式断点 ===== */
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
@media (max-width: 1120px) {
|
||||
.product-rules-layout {
|
||||
flex-direction: column;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.product-rules-form-card,
|
||||
.product-rules-list-card {
|
||||
flex: 1 1 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.product-rules-form-card {
|
||||
position: static;
|
||||
max-height: none;
|
||||
overflow-y: visible;
|
||||
|
||||
Reference in New Issue
Block a user