From 052b99b05f363f338bc6495ba16d4492555cf2f0 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 18 Aug 2026 16:55:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E9=87=8D=E6=9E=84=E6=8E=A5?= =?UTF-8?q?=E5=8D=95=E6=A8=A1=E6=9D=BF=E4=B8=8E=E5=95=86=E5=93=81=E7=BB=91?= =?UTF-8?q?=E5=AE=9A=E5=86=85=E8=81=94=E7=BC=96=E8=BE=91=20UI=20=E5=B8=83?= =?UTF-8?q?=E5=B1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 优化接单模板与模板列表 6:4 比例自适应布局 - 适用商品绑定支持左侧直接内联展开编辑,免弹窗模式 - 支持绑定规则多标签快速切换、实时新增与删除 - 基础表单与商品绑定统一采用 3 列网格布局,严格几何对齐 - 「启用拼单」开关及参数行内紧凑展开,移除多余背景框与纵向滚动条 --- .../worker-platform/product-match-repo.ts | 61 ++ .../src/routes/admin/worker-platform.ts | 2 +- .../services/worker-platform/admin-service.ts | 191 +++++- .../src/services/worker-platform/mappers.ts | 4 +- .../worker-platform-service.test.ts | 25 + .../pages/admin/AdminWorkerPlatformPage.tsx | 2 +- .../pages/admin/panels/ProductMatchPanel.tsx | 514 ++------------- .../pages/admin/panels/ProductRulesPanel.tsx | 587 +++++++++++++----- .../admin/panels/WorkProductBindingForm.tsx | 438 +++++++++++++ .../src/services/admin/worker-platform.ts | 9 +- apps/frontend/src/styles/admin.css | 297 ++++++--- 11 files changed, 1393 insertions(+), 737 deletions(-) create mode 100644 apps/frontend/src/pages/admin/panels/WorkProductBindingForm.tsx diff --git a/apps/backend/src/repositories/worker-platform/product-match-repo.ts b/apps/backend/src/repositories/worker-platform/product-match-repo.ts index 539f75aa..e4f06cc6 100644 --- a/apps/backend/src/repositories/worker-platform/product-match-repo.ts +++ b/apps/backend/src/repositories/worker-platform/product-match-repo.ts @@ -14,6 +14,7 @@ export async function listWorkProductRuleMappings( input: { enabled?: boolean | null sellerId?: string + ruleId?: number | string | null } = {}, ): Promise { 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( `${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( + `${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 diff --git a/apps/backend/src/routes/admin/worker-platform.ts b/apps/backend/src/routes/admin/worker-platform.ts index c2e5f451..41b54ec1 100644 --- a/apps/backend/src/routes/admin/worker-platform.ts +++ b/apps/backend/src/routes/admin/worker-platform.ts @@ -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]', diff --git a/apps/backend/src/services/worker-platform/admin-service.ts b/apps/backend/src/services/worker-platform/admin-service.ts index 0845f18c..75b45d92 100644 --- a/apps/backend/src/services/worker-platform/admin-service.ts +++ b/apps/backend/src/services/worker-platform/admin-service.ts @@ -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 || diff --git a/apps/backend/src/services/worker-platform/mappers.ts b/apps/backend/src/services/worker-platform/mappers.ts index d07d04d4..b0c1e35a 100644 --- a/apps/backend/src/services/worker-platform/mappers.ts +++ b/apps/backend/src/services/worker-platform/mappers.ts @@ -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 diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index 15d200dc..c30ed5f4 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -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个' diff --git a/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx b/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx index c40b3453..8ca2d948 100644 --- a/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx +++ b/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx @@ -50,7 +50,7 @@ export default function AdminWorkerPlatformPage() { : [ { key: 'orders', label: '接单工单', children: }, { key: 'rules', label: '接单模板', children: }, - { key: 'product-match', label: '商品匹配', children: }, + { key: 'product-match', label: '匹配诊断', children: }, { key: 'categories', label: '分类', children: }, { key: 'workers', label: '打手', children: }, { key: 'finance', label: '资金', children: }, diff --git a/apps/frontend/src/pages/admin/panels/ProductMatchPanel.tsx b/apps/frontend/src/pages/admin/panels/ProductMatchPanel.tsx index a9a26be8..bf170e46 100644 --- a/apps/frontend/src/pages/admin/panels/ProductMatchPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ProductMatchPanel.tsx @@ -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() + const navigate = useNavigate() const [testPayload, setTestPayload] = useState('') const [testResult, setTestResult] = useState< Awaited>['data'] | null >(null) const [selectedLog, setSelectedLog] = useState(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) { - 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 = [ - { - title: '店铺 / 商品', - key: 'product', - render: (_, row) => ( -
- - {mappingProductScopeLabel(row)} - - - 店铺{' '} - {row.sellerIds - .map((sellerId) => formatShopLabel(sellerId, shopNameBySellerId)) - .join('、')}{' '} - · {mappingProductScopeLabel(row)} - - {row.mappingType !== 'product_default' ? ( - - {row.mappingType === 'sku_series' ? 'SKU 系列' : 'SKU'} {row.skuNick || row.relSkuId} - {row.mappingType === 'sku_series' - ? ` · ${row.skuMatchMode === 'exact' ? '精确' : '模糊'}` - : ''} - - ) : null} -
- ), - }, - { - title: '映射类型', - dataIndex: 'mappingType', - width: 110, - render: (value) => ( - - {value === 'sku_exact' - ? 'SKU 精确' - : value === 'sku_series' - ? 'SKU 数量系列' - : '商品默认'} - - ), - }, - { - title: '接单模板', - key: 'template', - width: 170, - render: (_, row) => ( -
- {row.productName || row.ruleKey} - - {row.ruleKey} - -
- ), - }, - { - title: '状态', - dataIndex: 'enabled', - width: 80, - render: (enabled) => ( - {enabled ? '启用' : '停用'} - ), - }, - { - title: '操作', - key: 'actions', - width: 100, - render: (_, row) => ( - - - deleteMapping(row.mappingId)}> - - - - - - - - rowKey="mappingId" - loading={mappingsQuery.isLoading} - columns={mappingColumns} - dataSource={mappings} - pagination={{ pageSize: 10, showSizeChanger: false, showTotal: (total) => `共 ${total} 条` }} - size="small" - /> - -
- - 已接收的快手商品与 SKU - - - - 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" - /> -
+ 刷新 + + + 发现新商品后,点击「绑定到模板」在接单模板页配置匹配 + + + + 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" + /> ), }, @@ -697,73 +352,6 @@ function MatchStatusTag({ status }: { status: string }) { return {label} } -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 ( -
- - onChange?.(event.target.checked ? [...otherValues, ...optionValues] : otherValues) - } - > - 全选店铺({optionValues.length}) - - onChange?.(nextValues.map(String))} - style={{ - display: 'grid', - gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', - gap: 8, - width: '100%', - }} - > - {options.map((option) => ( - - {option.label} - - ))} - -
- ) -} - -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() for (const shop of shops) { diff --git a/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx b/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx index 32477ee6..49ca48a9 100644 --- a/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx @@ -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(null) + const [activeMappingId, setActiveMappingId] = useState(null) + const [sourcePrefill, setSourcePrefill] = useState(null) + const [keywordInput, setKeywordInput] = useState('') const [keyword, setKeyword] = useState('') const [categoryId, setCategoryId] = useState() @@ -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() 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 = [ + const bindingSummaryColumns: TableColumnsType = [ { - title: '模板 / 商品', - key: 'rule', - width: '34%', + title: '店铺 / 商品', + key: 'product', render: (_, row) => (
- + + {row.itemTitle || (row.relItemId ? `指定商品 ${row.relItemId}` : '全部商品')} + + + 店铺{' '} + {row.sellerIds + .map((sellerId) => formatShopLabel(sellerId, shopNameBySellerId)) + .join('、')} + +
+ ), + }, + { + title: '类别', + dataIndex: 'mappingType', + width: 110, + render: (value) => ( + + {value === 'sku_exact' + ? 'SKU 精确' + : value === 'sku_series' + ? 'SKU 数量系列' + : '商品默认'} + + ), + }, + { + title: 'SKU 条件', + key: 'sku', + width: 140, + render: (_, row) => + row.mappingType === 'product_default' ? ( + - + ) : ( + + {row.skuNick || row.relSkuId || '-'} + + ), + }, + { + title: '模式', + key: 'mode', + width: 65, + render: (_, row) => + row.mappingType === 'sku_series' ? ( + {row.skuMatchMode === 'exact' ? '精确' : '模糊'} + ) : ( + - + ), + }, + { + title: '状态', + dataIndex: 'enabled', + width: 65, + render: (enabled) => ( + + {enabled ? '启用' : '停用'} + + ), + }, + { + title: '操作', + key: 'actions', + width: 85, + render: (_, row) => ( + + + deleteBinding(row)} + > + ) : null} - } @@ -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} > -
- - - - - - - - + {/* 基础信息 */} +
+
+ {/* Row 2: 计价方式 + 单价/金额 + 任务时限 */} +
getFieldValue('pricingMode') === 'unit' ? ( @@ -565,14 +749,13 @@ export default function ProductRulesPanel() { } - - - - +
+ {/* Row 3: 超时策略 + 来源 + 平台 */} +
- - - - - + + +
- - {({ getFieldValue }) => { - const sharingEnabled = getFieldValue('sharingEnabled') - const unitMode = getFieldValue('pricingMode') === 'unit' - if (!sharingEnabled) return null + {/* Row 4: 排序权重 + 功能开关与拼单行内参数 */} +
+ + + + + + {({ getFieldValue }) => { + const sharingEnabled = getFieldValue('sharingEnabled') + const unitMode = getFieldValue('pricingMode') === 'unit' - if (unitMode) { return ( -
- - 默认按匹配 SKU 数量和上方单价自动生成拼单;填写以下两项后改为固定配置。 - - - +
+ + - - + + + + + + + {sharingEnabled ? ( +
+ + + !unitMode && + syncSharingRuleForm({ sharingTotalQuantity: Number(value) || 0 }) + } + /> + + + + !unitMode && + syncSharingRuleForm({ sharingUnitReward: Number(value) || 0 }) + } + /> + + {!unitMode ? ( + + + syncSharingRuleForm({ sharingTotalAmount: Number(value) || 0 }) + } + /> + + ) : null} +
+ ) : null}
) - } + }} +
+
- return ( -
- - - syncSharingRuleForm({ sharingTotalQuantity: Number(value) || 0 }) - } - /> - - - - syncSharingRuleForm({ sharingUnitReward: Number(value) || 0 }) - } - /> - - - - syncSharingRuleForm({ sharingTotalAmount: Number(value) || 0 }) - } - /> - -
- ) - }} -
- - {/* 资料字段: 默认展开 4 行全部清晰可见,无内嵌滚动条 */} + {/* 资料字段 */} + + + + {/* 适用商品绑定区域 */} +
+
+ + + + 适用商品绑定 + + {editingRule ? ( + 0 ? 'blue' : 'default'} style={{ margin: 0 }}> + 已绑定 {ruleMappings.length} 条规则 + + ) : null} + +
+ + {editingRule ? ( +
+ {/* 内联编辑表单(直接展开,免弹窗) */} + setActiveMappingId(id)} + onDeleteMapping={deleteBinding} + onSaved={handleBindingSaved} + /> +
+ ) : ( +
+ + 💡 填写并保存接单模板后,即可在此直接配置适用商品绑定与 SKU 匹配规则。 + +
+ )} +
- {/* Right Table Card - 60% Width, Zero Horizontal Scrollbar */} + {/* Right Table Card - 40% Width */} + 接单模板列表 - {rulesPagination?.total || 0} 条规则 + {rulesPagination?.total || 0} } extra={ - + { const value = event.target.value @@ -714,14 +937,15 @@ export default function ProductRulesPanel() { /> - - } @@ -731,6 +955,7 @@ export default function ProductRulesPanel() {
{ setCategoryId(key === 'all' ? undefined : Number(key)) setPage(1) @@ -750,6 +975,7 @@ export default function ProductRulesPanel() { 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() + 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) { + const name = shopNameBySellerId.get(sellerId) + return name ? `${name}(${sellerId})` : sellerId +} diff --git a/apps/frontend/src/pages/admin/panels/WorkProductBindingForm.tsx b/apps/frontend/src/pages/admin/panels/WorkProductBindingForm.tsx new file mode 100644 index 00000000..f8b3219b --- /dev/null +++ b/apps/frontend/src/pages/admin/panels/WorkProductBindingForm.tsx @@ -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() + 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) { + 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 ( + + + + + 商品匹配规则: + + {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 ( + onSelectMapping(m.mappingId)} + className={`binding-pill-tag ${isSelected ? 'active' : ''}`} + > + #{index + 1} {label} + + ) + })} + + +
+ } + extra={ + + {currentMapping && !isNew && onDeleteMapping ? ( + onDeleteMapping(currentMapping)} + > + + + ) : null} + + + } + > +
+ + + + {/* 覆盖店铺 */} + + + 覆盖店铺 + + } + name="sellerIds" + rules={[{ required: true, message: '至少选择一个店铺' }]} + > + + + + {/* 绑定类型 & 商品范围 & 状态 */} +
+ + + + ) : ( + + + + ) + }} + + + + + +
+ + {/* 商品ID / 大标题 / SKU 条件 */} + + {({ getFieldValue }) => { + const mappingType = getFieldValue('mappingType') + const isProductDefault = mappingType === 'product_default' + const productScope = getFieldValue('productScope') + const showProductScope = isProductDefault || productScope === 'selected_products' + + return ( +
+ {showProductScope ? ( + + + + ) : null} + + {!isProductDefault && showProductScope ? ( + + + + ) : null} + + {mappingType === 'sku_exact' ? ( + <> + + + + + + + + ) : mappingType === 'sku_series' ? ( + <> + + + + +