feat(admin): 重构接单模板与商品绑定内联编辑 UI 布局

- 优化接单模板与模板列表 6:4 比例自适应布局
- 适用商品绑定支持左侧直接内联展开编辑,免弹窗模式
- 支持绑定规则多标签快速切换、实时新增与删除
- 基础表单与商品绑定统一采用 3 列网格布局,严格几何对齐
- 「启用拼单」开关及参数行内紧凑展开,移除多余背景框与纵向滚动条
This commit is contained in:
yml2213
2026-08-18 16:55:58 +08:00
parent be6f4f2507
commit 052b99b05f
11 changed files with 1393 additions and 737 deletions
@@ -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.relSkuId0 自动忽略" />
</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:游戏编号&#10;gameNickname:游戏昵称&#10;system:系统#安卓,苹果&#10;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.relSkuId0 自动忽略" />
</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: {
+220 -77
View File
@@ -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;