feat(admin): 重构接单模板与商品绑定内联编辑 UI 布局
- 优化接单模板与模板列表 6:4 比例自适应布局 - 适用商品绑定支持左侧直接内联展开编辑,免弹窗模式 - 支持绑定规则多标签快速切换、实时新增与删除 - 基础表单与商品绑定统一采用 3 列网格布局,严格几何对齐 - 「启用拼单」开关及参数行内紧凑展开,移除多余背景框与纵向滚动条
This commit is contained in:
@@ -14,6 +14,7 @@ export async function listWorkProductRuleMappings(
|
|||||||
input: {
|
input: {
|
||||||
enabled?: boolean | null
|
enabled?: boolean | null
|
||||||
sellerId?: string
|
sellerId?: string
|
||||||
|
ruleId?: number | string | null
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<WorkProductRuleMappingRow[]> {
|
): Promise<WorkProductRuleMappingRow[]> {
|
||||||
const conditions: string[] = []
|
const conditions: string[] = []
|
||||||
@@ -29,6 +30,11 @@ export async function listWorkProductRuleMappings(
|
|||||||
params.push(sellerId)
|
params.push(sellerId)
|
||||||
conditions.push(`wprm.seller_ids_json ? $${params.length}`)
|
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 where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
|
||||||
const result = await query<WorkProductRuleMappingRow>(
|
const result = await query<WorkProductRuleMappingRow>(
|
||||||
`${MAPPING_SELECT}
|
`${MAPPING_SELECT}
|
||||||
@@ -41,6 +47,61 @@ export async function listWorkProductRuleMappings(
|
|||||||
return result.rows
|
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: {
|
export async function upsertWorkProductRuleMapping(input: {
|
||||||
mappingId?: number | null
|
mappingId?: number | null
|
||||||
ruleId: number
|
ruleId: number
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ router.delete(
|
|||||||
router.get(
|
router.get(
|
||||||
'/worker-platform/product-mappings',
|
'/worker-platform/product-mappings',
|
||||||
requireAdminRoles(['admin', 'operator', 'support']),
|
requireAdminRoles(['admin', 'operator', 'support']),
|
||||||
createJsonHandler(() => listAdminWorkProductRuleMappings(), {
|
createJsonHandler((req) => listAdminWorkProductRuleMappings(req.query), {
|
||||||
successMessage: 'ok',
|
successMessage: 'ok',
|
||||||
errorMessage: '读取商品映射失败',
|
errorMessage: '读取商品映射失败',
|
||||||
scope: '[admin/worker-platform/product-mappings]',
|
scope: '[admin/worker-platform/product-mappings]',
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
listWorkOrderEventsByOrderId,
|
listWorkOrderEventsByOrderId,
|
||||||
listWorkProductRules,
|
listWorkProductRules,
|
||||||
listWorkProductRuleMappings,
|
listWorkProductRuleMappings,
|
||||||
|
listWorkProductRuleMappingsPage,
|
||||||
listWorkProductMatchLogs,
|
listWorkProductMatchLogs,
|
||||||
listWorkProductRulesPage,
|
listWorkProductRulesPage,
|
||||||
listWorkerLevels,
|
listWorkerLevels,
|
||||||
@@ -283,8 +284,25 @@ export async function reprocessAdminKuaishouSendCodeWorkOrders(payload: JsonObje
|
|||||||
return reprocessKuaishouSendCodeWorkOrders(limit)
|
return reprocessKuaishouSendCodeWorkOrders(limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listAdminWorkProductRuleMappings() {
|
export async function listAdminWorkProductRuleMappings(query: JsonObject = {}) {
|
||||||
const items = await listWorkProductRuleMappings()
|
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) }
|
return { items: items.map(mapWorkProductRuleMapping) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,8 +377,20 @@ export async function saveAdminWorkProductRuleMapping(payload: JsonObject = {})
|
|||||||
errorCode: 'work_product_mapping_rule_not_found',
|
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({
|
const mapping = await upsertWorkProductRuleMapping({
|
||||||
mappingId: normalizeOptionalId(payload.mappingId ?? payload.mapping_id),
|
mappingId,
|
||||||
ruleId,
|
ruleId,
|
||||||
sellerIds,
|
sellerIds,
|
||||||
relItemId,
|
relItemId,
|
||||||
@@ -547,6 +577,10 @@ function normalizeKuaishouMatchId(value: unknown) {
|
|||||||
return normalized === '0' ? '' : normalized
|
return normalized === '0' ? '' : normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasOwnPayload(payload: JsonObject, key: string) {
|
||||||
|
return Object.prototype.hasOwnProperty.call(payload, key)
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeKuaishouMatchIds(value: unknown) {
|
function normalizeKuaishouMatchIds(value: unknown) {
|
||||||
const values = Array.isArray(value)
|
const values = Array.isArray(value)
|
||||||
? value
|
? value
|
||||||
@@ -580,6 +614,92 @@ function normalizeMatchTextForAdmin(value: unknown) {
|
|||||||
.trim()
|
.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[] {
|
function parseJsonArray(value: unknown): unknown[] {
|
||||||
if (Array.isArray(value)) return value
|
if (Array.isArray(value)) return value
|
||||||
try {
|
try {
|
||||||
@@ -592,7 +712,38 @@ function parseJsonArray(value: unknown): unknown[] {
|
|||||||
|
|
||||||
export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
|
export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
|
||||||
const defaults = await ensureWorkerPlatformDefaults()
|
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(
|
const productName = String(
|
||||||
payload.productName || payload.product_name || payload.skuName || '',
|
payload.productName || payload.product_name || payload.skuName || '',
|
||||||
).trim()
|
).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 rewardAmount = normalizeAmountFen(payload.rewardAmount ?? payload.rewardAmountYuan, 0)
|
||||||
const unitPriceFen =
|
const unitPriceFen =
|
||||||
payload.unitPrice === undefined && payload.unitPriceYuan === undefined
|
payload.unitPrice === undefined && payload.unitPriceYuan === undefined
|
||||||
@@ -678,12 +820,25 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
|
|||||||
const ruleKey = existingRule?.rule_key || randomId('rule-')
|
const ruleKey = existingRule?.rule_key || randomId('rule-')
|
||||||
const rule = await upsertWorkProductRule({
|
const rule = await upsertWorkProductRule({
|
||||||
ruleKey,
|
ruleKey,
|
||||||
provider: String(payload.provider || '').trim(),
|
provider: hasOwnPayload(payload, 'provider')
|
||||||
platform: String(payload.platform || '').trim(),
|
? String(payload.provider || '').trim()
|
||||||
shopId: String(payload.shopId || payload.shop_id || '').trim(),
|
: String(existingRule?.provider || '').trim(),
|
||||||
skuCode,
|
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,
|
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:
|
categoryId:
|
||||||
normalizeOptionalId(payload.categoryId || payload.category_id) ||
|
normalizeOptionalId(payload.categoryId || payload.category_id) ||
|
||||||
defaults.category?.id ||
|
defaults.category?.id ||
|
||||||
|
|||||||
@@ -738,9 +738,9 @@ function scoreWorkProductRuleMapping(
|
|||||||
)
|
)
|
||||||
const hasProductScope = Boolean(mappedItemIds.length > 0 || mappedTitle)
|
const hasProductScope = Boolean(mappedItemIds.length > 0 || mappedTitle)
|
||||||
if (mappingType === 'product_default' || hasProductScope) {
|
if (mappingType === 'product_default' || hasProductScope) {
|
||||||
// 有商品 ID 时只能按 ID 命中,避免同店同标题的不同商品串单;缺失 ID 才按标题回退。
|
// 配置了商品 ID 时必须要求订单也提供商品 ID,避免缺少 ID 时误落入同店其他商品。
|
||||||
if (
|
if (
|
||||||
(mappedItemIds.length > 0 && hasContextItemId && !itemIdMatched) ||
|
(mappedItemIds.length > 0 && (!hasContextItemId || !itemIdMatched)) ||
|
||||||
(mappedItemIds.length === 0 && !titleMatched)
|
(mappedItemIds.length === 0 && !titleMatched)
|
||||||
) {
|
) {
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -351,6 +351,31 @@ test('商品默认映射支持用逗号填写多个关联商品 ID', () => {
|
|||||||
assert.equal(decision.rule?.id, rule.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 数量系列不会按公共词误匹配其他系列', () => {
|
test('SKU 数量系列不会按公共词误匹配其他系列', () => {
|
||||||
const item = buildOrderItemRow()
|
const item = buildOrderItemRow()
|
||||||
item.sku_name = '指挥官隐藏款1个'
|
item.sku_name = '指挥官隐藏款1个'
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export default function AdminWorkerPlatformPage() {
|
|||||||
: [
|
: [
|
||||||
{ key: 'orders', label: '接单工单', children: <WorkOrdersPanel /> },
|
{ key: 'orders', label: '接单工单', children: <WorkOrdersPanel /> },
|
||||||
{ key: 'rules', label: '接单模板', children: <ProductRulesPanel /> },
|
{ key: 'rules', label: '接单模板', children: <ProductRulesPanel /> },
|
||||||
{ key: 'product-match', label: '商品匹配', children: <ProductMatchPanel /> },
|
{ key: 'product-match', label: '匹配诊断', children: <ProductMatchPanel /> },
|
||||||
{ key: 'categories', label: '分类', children: <CategoriesPanel /> },
|
{ key: 'categories', label: '分类', children: <CategoriesPanel /> },
|
||||||
{ key: 'workers', label: '打手', children: <WorkersPanel /> },
|
{ key: 'workers', label: '打手', children: <WorkersPanel /> },
|
||||||
{ key: 'finance', label: '资金', children: <FinancePanel /> },
|
{ 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 { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
App,
|
App,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Checkbox,
|
|
||||||
Descriptions,
|
Descriptions,
|
||||||
Form,
|
|
||||||
Input,
|
Input,
|
||||||
Modal,
|
Modal,
|
||||||
Popconfirm,
|
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Switch,
|
Switch,
|
||||||
@@ -20,66 +17,32 @@ import {
|
|||||||
} from 'antd'
|
} from 'antd'
|
||||||
import type { TableColumnsType } from 'antd'
|
import type { TableColumnsType } from 'antd'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router'
|
||||||
|
|
||||||
import JsonPreview from '@/components/admin/JsonPreview'
|
import JsonPreview from '@/components/admin/JsonPreview'
|
||||||
import {
|
import {
|
||||||
deleteAdminWorkProductRuleMapping,
|
|
||||||
fetchAdminKuaishouIndustryShops,
|
fetchAdminKuaishouIndustryShops,
|
||||||
fetchAdminKuaishouMatchSources,
|
fetchAdminKuaishouMatchSources,
|
||||||
fetchAdminWorkerProductMatchConfig,
|
fetchAdminWorkerProductMatchConfig,
|
||||||
fetchAdminWorkProductMatchLogs,
|
fetchAdminWorkProductMatchLogs,
|
||||||
fetchAdminWorkProductRuleMappings,
|
|
||||||
fetchAdminWorkProductRules,
|
|
||||||
saveAdminWorkProductRuleMapping,
|
|
||||||
saveAdminWorkerProductMatchConfig,
|
saveAdminWorkerProductMatchConfig,
|
||||||
testAdminKuaishouProductMatch,
|
testAdminKuaishouProductMatch,
|
||||||
} from '@/services/admin'
|
} from '@/services/admin'
|
||||||
import type {
|
import type { KuaishouMatchSource, WorkProductMatchLog } from '@/types/worker-platform'
|
||||||
KuaishouMatchSource,
|
|
||||||
WorkProductMatchLog,
|
|
||||||
WorkProductRuleMapping,
|
|
||||||
WorkerProductMatchConfig,
|
|
||||||
} from '@/types/worker-platform'
|
|
||||||
import type { AdminKuaishouIndustryShopOption } from '@/types/admin'
|
import type { AdminKuaishouIndustryShopOption } from '@/types/admin'
|
||||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
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() {
|
export default function ProductMatchPanel() {
|
||||||
const { message } = App.useApp()
|
const { message } = App.useApp()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [mappingForm] = Form.useForm<MappingFormValues>()
|
const navigate = useNavigate()
|
||||||
const [testPayload, setTestPayload] = useState('')
|
const [testPayload, setTestPayload] = useState('')
|
||||||
const [testResult, setTestResult] = useState<
|
const [testResult, setTestResult] = useState<
|
||||||
Awaited<ReturnType<typeof testAdminKuaishouProductMatch>>['data'] | null
|
Awaited<ReturnType<typeof testAdminKuaishouProductMatch>>['data'] | null
|
||||||
>(null)
|
>(null)
|
||||||
const [selectedLog, setSelectedLog] = useState<WorkProductMatchLog | 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({
|
const shopsQuery = useQuery({
|
||||||
queryKey: ['admin-kuaishou-industry-shops', 'product-match'],
|
queryKey: ['admin-kuaishou-industry-shops', 'product-match'],
|
||||||
queryFn: fetchAdminKuaishouIndustryShops,
|
queryFn: fetchAdminKuaishouIndustryShops,
|
||||||
@@ -97,111 +60,23 @@ export default function ProductMatchPanel() {
|
|||||||
queryFn: fetchAdminWorkerProductMatchConfig,
|
queryFn: fetchAdminWorkerProductMatchConfig,
|
||||||
})
|
})
|
||||||
|
|
||||||
const rules = rulesQuery.data?.data.items || []
|
|
||||||
const mappings = mappingsQuery.data?.data.items || []
|
|
||||||
const sources = sourcesQuery.data?.data.items || []
|
const sources = sourcesQuery.data?.data.items || []
|
||||||
const logs = logsQuery.data?.data.items || []
|
const logs = logsQuery.data?.data.items || []
|
||||||
const shops = shopsQuery.data?.data.shops || []
|
const shops = shopsQuery.data?.data.shops || []
|
||||||
const matchConfig = matchConfigQuery.data?.data
|
const matchConfig = matchConfigQuery.data?.data
|
||||||
const shopNameBySellerId = createShopNameBySellerId(shops)
|
const shopNameBySellerId = createShopNameBySellerId(shops)
|
||||||
const sellerIds = new Set([
|
|
||||||
...shops.map((shop) => shop.sellerId),
|
|
||||||
...sources.map((source) => source.sellerId),
|
|
||||||
])
|
|
||||||
const sellerOptions = Array.from(sellerIds)
|
|
||||||
.filter(Boolean)
|
|
||||||
.sort((left, right) => left.localeCompare(right))
|
|
||||||
.map((sellerId) => ({ value: sellerId, label: formatShopLabel(sellerId, shopNameBySellerId) }))
|
|
||||||
|
|
||||||
function resetMappingForm() {
|
function bindToTemplate(source: KuaishouMatchSource) {
|
||||||
mappingForm.resetFields()
|
const params = new URLSearchParams({
|
||||||
mappingForm.setFieldsValue({
|
tab: 'rules',
|
||||||
mappingType: 'product_default',
|
bind: '1',
|
||||||
productScope: 'selected_products',
|
sellerId: source.sellerId,
|
||||||
skuMatchMode: 'fuzzy',
|
relItemId: source.relItemId || '',
|
||||||
enabled: true,
|
itemTitle: source.itemTitle || '',
|
||||||
|
relSkuId: source.relSkuId || '',
|
||||||
|
skuNick: source.skuNick || '',
|
||||||
})
|
})
|
||||||
}
|
void navigate(`/admin/worker-platform?${params.toString()}`)
|
||||||
|
|
||||||
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 : '删除商品映射失败')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runMatchTest() {
|
async function runMatchTest() {
|
||||||
@@ -218,10 +93,9 @@ export default function ProductMatchPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function updateLegacyFallback(legacyFallbackEnabled: boolean) {
|
async function updateLegacyFallback(legacyFallbackEnabled: boolean) {
|
||||||
const nextConfig: WorkerProductMatchConfig = { legacyFallbackEnabled }
|
|
||||||
try {
|
try {
|
||||||
await saveAdminWorkerProductMatchConfig(nextConfig)
|
await saveAdminWorkerProductMatchConfig({ legacyFallbackEnabled })
|
||||||
message.success(legacyFallbackEnabled ? '旧规则兼容匹配已开启' : '旧规则兼容匹配已关闭')
|
message.success(legacyFallbackEnabled ? '兼容旧模板匹配已开启' : '兼容旧模板匹配已关闭')
|
||||||
await queryClient.invalidateQueries({
|
await queryClient.invalidateQueries({
|
||||||
queryKey: ['admin-worker-platform-product-match-config'],
|
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> = [
|
const sourceColumns: TableColumnsType<KuaishouMatchSource> = [
|
||||||
{
|
{
|
||||||
title: '快手商品 / SKU',
|
title: '快手商品 / SKU',
|
||||||
@@ -339,22 +134,17 @@ export default function ProductMatchPanel() {
|
|||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
width: 200,
|
width: 160,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Space size={4} wrap>
|
<Space size={4} wrap>
|
||||||
<Button type="link" onClick={() => fillMappingFromSource(row, 'product_default')}>
|
<Button type="link" onClick={() => bindToTemplate(row)}>
|
||||||
设为商品默认
|
绑定到模板
|
||||||
</Button>
|
|
||||||
<Button type="link" onClick={() => fillMappingFromSource(row, 'sku_exact')}>
|
|
||||||
设为 SKU 精确
|
|
||||||
</Button>
|
|
||||||
<Button type="link" onClick={() => fillMappingFromSource(row, 'sku_series')}>
|
|
||||||
设为 SKU 系列
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setTestPayload(JSON.stringify(row.rawPayload, null, 2))
|
setTestPayload(JSON.stringify(row.rawPayload, null, 2))
|
||||||
|
setActiveTab('test')
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
测试
|
测试
|
||||||
@@ -411,165 +201,28 @@ export default function ProductMatchPanel() {
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card bordered={false} title="商品匹配">
|
<Card bordered={false} title="匹配诊断">
|
||||||
<Space align="center" style={{ marginBottom: 16 }}>
|
<Space align="center" style={{ marginBottom: 16 }}>
|
||||||
<Switch
|
<Switch
|
||||||
checked={matchConfig?.legacyFallbackEnabled === true}
|
checked={matchConfig?.legacyFallbackEnabled === true}
|
||||||
loading={matchConfigQuery.isLoading || matchConfigQuery.isFetching}
|
loading={matchConfigQuery.isLoading || matchConfigQuery.isFetching}
|
||||||
onChange={updateLegacyFallback}
|
onChange={updateLegacyFallback}
|
||||||
/>
|
/>
|
||||||
<Typography.Text>启用旧规则兼容匹配</Typography.Text>
|
<Typography.Text>兼容旧模板匹配(迁移用)</Typography.Text>
|
||||||
<Typography.Text type="secondary">默认关闭;关闭后快手订单仅按商品映射匹配</Typography.Text>
|
<Typography.Text type="secondary">
|
||||||
|
默认关闭;正常快手订单按「接单模板 → 商品绑定」匹配
|
||||||
|
</Typography.Text>
|
||||||
</Space>
|
</Space>
|
||||||
<Tabs
|
<Tabs
|
||||||
|
activeKey={activeTab}
|
||||||
|
onChange={setActiveTab}
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
key: 'mappings',
|
key: 'sources',
|
||||||
label: `商品映射 (${mappings.length})`,
|
label: `已接收商品 (${sources.length})`,
|
||||||
children: (
|
children: (
|
||||||
<div className="page-stack">
|
<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}
|
|
||||||
>
|
|
||||||
<Form.Item name="mappingId" hidden>
|
|
||||||
<Input />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label="接单模板"
|
|
||||||
name="ruleId"
|
|
||||||
rules={[{ required: true, message: '请选择接单模板' }]}
|
|
||||||
>
|
|
||||||
<Select
|
|
||||||
showSearch
|
|
||||||
optionFilterProp="label"
|
|
||||||
placeholder="选择可复用模板"
|
|
||||||
options={rules.map((rule) => ({
|
|
||||||
value: rule.ruleId,
|
|
||||||
label: `${rule.productName || rule.ruleKey} (${rule.ruleKey})`,
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label="映射类型" name="mappingType">
|
|
||||||
<Select
|
|
||||||
options={[
|
|
||||||
{ value: 'product_default', label: '商品默认模板' },
|
|
||||||
{ value: 'sku_series', label: 'SKU 数量系列' },
|
|
||||||
{ value: 'sku_exact', label: 'SKU 精确覆盖' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item
|
|
||||||
label="覆盖店铺"
|
|
||||||
name="sellerIds"
|
|
||||||
rules={[{ required: true, message: '至少选择一个店铺' }]}
|
|
||||||
>
|
|
||||||
<ShopSelector options={sellerOptions} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item noStyle shouldUpdate>
|
|
||||||
{({ getFieldValue }) => {
|
|
||||||
const mappingType = getFieldValue('mappingType')
|
|
||||||
const isProductDefault = mappingType === 'product_default'
|
|
||||||
const productScope = getFieldValue('productScope')
|
|
||||||
const showProductScope =
|
|
||||||
isProductDefault || productScope === 'selected_products'
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{!isProductDefault ? (
|
|
||||||
<Form.Item label="商品范围" name="productScope">
|
|
||||||
<Select
|
|
||||||
options={[
|
|
||||||
{ value: 'all_products', label: '全部商品' },
|
|
||||||
{ value: 'selected_products', label: '指定商品' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
) : null}
|
|
||||||
{showProductScope ? (
|
|
||||||
<>
|
|
||||||
<Form.Item label="关联商品 ID" name="relItemId">
|
|
||||||
<Input.TextArea
|
|
||||||
rows={2}
|
|
||||||
placeholder="可填写多个;用逗号、中文逗号或换行分隔"
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label="快手大标题" name="itemTitle">
|
|
||||||
<Input
|
|
||||||
placeholder={
|
|
||||||
isProductDefault
|
|
||||||
? '商品默认规则需填写大标题或关联商品 ID'
|
|
||||||
: '可选;同名 SKU 需要按父商品分流时填写'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{mappingType === 'sku_exact' ? (
|
|
||||||
<>
|
|
||||||
<Form.Item label="关联 SKU ID" name="relSkuId">
|
|
||||||
<Input placeholder="ext.relSkuId,0 自动忽略" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label="具体 SKU 名称" name="skuNick">
|
|
||||||
<Input placeholder="ext.skuNick,完整精确匹配" />
|
|
||||||
</Form.Item>
|
|
||||||
</>
|
|
||||||
) : mappingType === 'sku_series' ? (
|
|
||||||
<>
|
|
||||||
<Form.Item label="SKU 匹配方式" name="skuMatchMode">
|
|
||||||
<Select
|
|
||||||
options={[
|
|
||||||
{ value: 'fuzzy', label: '模糊匹配(默认)' },
|
|
||||||
{ value: 'exact', label: '精确匹配' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label="SKU 系列名称" name="skuNick">
|
|
||||||
<Input placeholder="例如:指挥官密钥;自动覆盖 1个、10个等数量规格" />
|
|
||||||
</Form.Item>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label="启用" name="enabled" valuePropName="checked">
|
|
||||||
<Switch />
|
|
||||||
</Form.Item>
|
|
||||||
<Space>
|
<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
|
<Button
|
||||||
icon={<ReloadOutlined />}
|
icon={<ReloadOutlined />}
|
||||||
loading={sourcesQuery.isFetching}
|
loading={sourcesQuery.isFetching}
|
||||||
@@ -577,6 +230,9 @@ export default function ProductMatchPanel() {
|
|||||||
>
|
>
|
||||||
刷新
|
刷新
|
||||||
</Button>
|
</Button>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
发现新商品后,点击「绑定到模板」在接单模板页配置匹配
|
||||||
|
</Typography.Text>
|
||||||
</Space>
|
</Space>
|
||||||
<Table<KuaishouMatchSource>
|
<Table<KuaishouMatchSource>
|
||||||
rowKey={(row) =>
|
rowKey={(row) =>
|
||||||
@@ -589,7 +245,6 @@ export default function ProductMatchPanel() {
|
|||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -697,73 +352,6 @@ function MatchStatusTag({ status }: { status: string }) {
|
|||||||
return <Tag color={color}>{label}</Tag>
|
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[]) {
|
function createShopNameBySellerId(shops: AdminKuaishouIndustryShopOption[]) {
|
||||||
const names = new Map<string, string>()
|
const names = new Map<string, string>()
|
||||||
for (const shop of shops) {
|
for (const shop of shops) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
FormOutlined,
|
FormOutlined,
|
||||||
|
LinkOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
App,
|
App,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
|
Divider,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
@@ -24,10 +26,13 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import type { TableColumnsType } from 'antd'
|
import type { TableColumnsType } from 'antd'
|
||||||
import { useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
deleteAdminWorkProductRule,
|
deleteAdminWorkProductRule,
|
||||||
|
deleteAdminWorkProductRuleMapping,
|
||||||
|
fetchAdminKuaishouIndustryShops,
|
||||||
fetchAdminWorkCategories,
|
fetchAdminWorkCategories,
|
||||||
fetchAdminWorkProductRuleMappings,
|
fetchAdminWorkProductRuleMappings,
|
||||||
fetchAdminWorkProductRules,
|
fetchAdminWorkProductRules,
|
||||||
@@ -35,15 +40,22 @@ import {
|
|||||||
saveAdminWorkProductRule,
|
saveAdminWorkProductRule,
|
||||||
} from '@/services/admin'
|
} from '@/services/admin'
|
||||||
import type { CollectField, WorkProductRule, WorkProductRuleMapping } from '@/types/worker-platform'
|
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 { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
|
||||||
import { formatMoney } from './shared'
|
import { formatMoney } from './shared'
|
||||||
|
import { WorkProductBindingForm, type SourcePrefill } from './WorkProductBindingForm'
|
||||||
|
|
||||||
export default function ProductRulesPanel() {
|
export default function ProductRulesPanel() {
|
||||||
const { message } = App.useApp()
|
const { message } = App.useApp()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [form] = Form.useForm()
|
const [form] = Form.useForm()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
|
||||||
const [editingRule, setEditingRule] = useState<WorkProductRule | null>(null)
|
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 [keywordInput, setKeywordInput] = useState('')
|
||||||
const [keyword, setKeyword] = useState('')
|
const [keyword, setKeyword] = useState('')
|
||||||
const [categoryId, setCategoryId] = useState<number | undefined>()
|
const [categoryId, setCategoryId] = useState<number | undefined>()
|
||||||
@@ -64,18 +76,34 @@ export default function ProductRulesPanel() {
|
|||||||
queryKey: ['admin-worker-platform-product-mappings'],
|
queryKey: ['admin-worker-platform-product-mappings'],
|
||||||
queryFn: () => fetchAdminWorkProductRuleMappings(),
|
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({
|
const categoriesQuery = useQuery({
|
||||||
queryKey: ['admin-worker-platform-categories'],
|
queryKey: ['admin-worker-platform-categories'],
|
||||||
queryFn: () => fetchAdminWorkCategories(),
|
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 rules = rulesQuery.data?.data.items || []
|
||||||
const mappings = mappingsQuery.data?.data.items || []
|
const mappings = mappingsQuery.data?.data.items || []
|
||||||
const mappingsByRuleId = new Map<number, WorkProductRuleMapping[]>()
|
const mappingsByRuleId = new Map<number, WorkProductRuleMapping[]>()
|
||||||
for (const mapping of mappings) {
|
for (const mapping of mappings) {
|
||||||
const ruleMappings = mappingsByRuleId.get(mapping.ruleId) || []
|
const rMappings = mappingsByRuleId.get(mapping.ruleId) || []
|
||||||
ruleMappings.push(mapping)
|
rMappings.push(mapping)
|
||||||
mappingsByRuleId.set(mapping.ruleId, ruleMappings)
|
mappingsByRuleId.set(mapping.ruleId, rMappings)
|
||||||
}
|
}
|
||||||
const rulesPagination = rulesQuery.data?.data.pagination
|
const rulesPagination = rulesQuery.data?.data.pagination
|
||||||
const categoryCounts = new Map(
|
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) {
|
function searchRules(value = keywordInput) {
|
||||||
setKeyword(value.trim())
|
setKeyword(value.trim())
|
||||||
setPage(1)
|
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() {
|
function resetRuleForm() {
|
||||||
setEditingRule(null)
|
setEditingRule(null)
|
||||||
|
setActiveMappingId('new')
|
||||||
|
setSourcePrefill(null)
|
||||||
form.resetFields()
|
form.resetFields()
|
||||||
}
|
}
|
||||||
|
|
||||||
function editRule(rule: WorkProductRule) {
|
function editRule(rule: WorkProductRule) {
|
||||||
setEditingRule(rule)
|
setEditingRule(rule)
|
||||||
|
setActiveMappingId(null) // will auto-select first mapping in useEffect
|
||||||
const hasManualSharingOverride = hasManualSharingInput(
|
const hasManualSharingOverride = hasManualSharingInput(
|
||||||
rule.sharing?.totalQuantity,
|
rule.sharing?.totalQuantity,
|
||||||
rule.sharing?.unitReward,
|
rule.sharing?.unitReward,
|
||||||
@@ -181,17 +252,21 @@ export default function ProductRulesPanel() {
|
|||||||
Number(values.sharingUnitReward || 0) > 0
|
Number(values.sharingUnitReward || 0) > 0
|
||||||
: hasManualSharingInput(values.sharingTotalQuantity, values.sharingUnitReward)
|
: hasManualSharingInput(values.sharingTotalQuantity, values.sharingUnitReward)
|
||||||
const { pricingMode: _pricingMode, ...payload } = values
|
const { pricingMode: _pricingMode, ...payload } = values
|
||||||
await saveAdminWorkProductRule({
|
const res = await saveAdminWorkProductRule({
|
||||||
...payload,
|
...payload,
|
||||||
ruleId: editingRule?.ruleId,
|
ruleId: editingRule?.ruleId,
|
||||||
ruleKey: editingRule?.ruleKey || '',
|
ruleKey: editingRule?.ruleKey || '',
|
||||||
match: editingRule?.match,
|
|
||||||
unitPrice: unitMode ? Number(values.unitPrice || 0) : 0,
|
unitPrice: unitMode ? Number(values.unitPrice || 0) : 0,
|
||||||
sharingAutoFromOrder:
|
sharingAutoFromOrder:
|
||||||
unitMode && values.sharingEnabled === true && !hasManualSharingOverride,
|
unitMode && values.sharingEnabled === true && !hasManualSharingOverride,
|
||||||
})
|
})
|
||||||
message.success(editingRule ? '接单模板已更新' : '接单模板已创建')
|
message.success(editingRule ? '接单模板已更新' : '接单模板已创建')
|
||||||
|
const savedRule = res.data?.rule
|
||||||
|
if (savedRule) {
|
||||||
|
editRule(savedRule)
|
||||||
|
} else {
|
||||||
resetRuleForm()
|
resetRuleForm()
|
||||||
|
}
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ['admin-worker-platform-product-rules'],
|
queryKey: ['admin-worker-platform-product-rules'],
|
||||||
@@ -216,11 +291,7 @@ export default function ProductRulesPanel() {
|
|||||||
ruleKey: rule.ruleKey,
|
ruleKey: rule.ruleKey,
|
||||||
provider: rule.provider,
|
provider: rule.provider,
|
||||||
platform: rule.platform,
|
platform: rule.platform,
|
||||||
shopId: rule.shopId,
|
|
||||||
skuCode: rule.skuCode,
|
|
||||||
productName: rule.productName,
|
productName: rule.productName,
|
||||||
matchType: rule.matchType,
|
|
||||||
...rule.match,
|
|
||||||
categoryId: rule.categoryId || undefined,
|
categoryId: rule.categoryId || undefined,
|
||||||
rewardAmount: rule.rewardAmount / 100,
|
rewardAmount: rule.rewardAmount / 100,
|
||||||
unitPrice: rule.unitPriceFen / 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() {
|
async function reprocessKuaishouOrders() {
|
||||||
try {
|
try {
|
||||||
const response = await reprocessAdminKuaishouWorkOrderMatches()
|
const response = await reprocessAdminKuaishouWorkOrderMatches()
|
||||||
@@ -307,20 +403,128 @@ export default function ProductRulesPanel() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns: TableColumnsType<WorkProductRule> = [
|
const bindingSummaryColumns: TableColumnsType<WorkProductRuleMapping> = [
|
||||||
{
|
{
|
||||||
title: '模板 / 商品',
|
title: '店铺 / 商品',
|
||||||
key: 'rule',
|
key: 'product',
|
||||||
width: '34%',
|
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<div className="cell-stack">
|
<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}
|
{row.productName || row.ruleKey}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
<Space size={4} wrap style={{ marginTop: 2 }}>
|
<Space size={4} wrap style={{ marginTop: 2 }}>
|
||||||
<Tag color="blue" style={{ margin: 0, fontSize: 11, lineHeight: '18px' }}>
|
<Tag color="blue" style={{ margin: 0, fontSize: 11, lineHeight: '18px' }}>
|
||||||
{row.ruleKey}
|
{row.ruleKey}
|
||||||
</Tag>
|
</Tag>
|
||||||
|
{row.categoryName ? (
|
||||||
|
<Tag color="cyan" style={{ margin: 0, fontSize: 11, lineHeight: '18px' }}>
|
||||||
|
{row.categoryName}
|
||||||
|
</Tag>
|
||||||
|
) : null}
|
||||||
{row.provider ? (
|
{row.provider ? (
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
|
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
{row.provider}
|
{row.provider}
|
||||||
@@ -331,27 +535,14 @@ export default function ProductRulesPanel() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '分类',
|
title: '已绑定商品',
|
||||||
dataIndex: 'categoryName',
|
|
||||||
width: '12%',
|
|
||||||
render: (value) =>
|
|
||||||
value ? (
|
|
||||||
<Tag color="cyan" style={{ margin: 0 }}>
|
|
||||||
{value}
|
|
||||||
</Tag>
|
|
||||||
) : (
|
|
||||||
<Typography.Text type="secondary">-</Typography.Text>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '已匹配商品',
|
|
||||||
key: 'productMappings',
|
key: 'productMappings',
|
||||||
width: '18%',
|
width: '24%',
|
||||||
render: (_, row) => {
|
render: (_, row) => {
|
||||||
const ruleMappings = mappingsByRuleId.get(row.ruleId) || []
|
const rMappings = mappingsByRuleId.get(row.ruleId) || []
|
||||||
const enabledCount = ruleMappings.filter((mapping) => mapping.enabled).length
|
const enabledCount = rMappings.filter((m) => m.enabled).length
|
||||||
const disabledCount = ruleMappings.length - enabledCount
|
const disabledCount = rMappings.length - enabledCount
|
||||||
return ruleMappings.length > 0 ? (
|
return rMappings.length > 0 ? (
|
||||||
<div className="cell-stack">
|
<div className="cell-stack">
|
||||||
<Tag
|
<Tag
|
||||||
color={enabledCount > 0 ? 'green' : 'default'}
|
color={enabledCount > 0 ? 'green' : 'default'}
|
||||||
@@ -373,11 +564,11 @@ export default function ProductRulesPanel() {
|
|||||||
{
|
{
|
||||||
title: '接单金额',
|
title: '接单金额',
|
||||||
key: 'reward',
|
key: 'reward',
|
||||||
width: '14%',
|
width: '20%',
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<div className="cell-stack">
|
<div className="cell-stack">
|
||||||
<Typography.Text strong style={{ color: '#3f8600', fontSize: 13 }}>
|
<Typography.Text strong style={{ color: '#3f8600', fontSize: 12 }}>
|
||||||
{row.sharing?.autoFromOrder ? '随订单自动计算' : formatMoney(row.rewardAmount)}
|
{row.sharing?.autoFromOrder ? '随订单计算' : formatMoney(row.rewardAmount)}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
{row.unitPriceFen > 0 ? (
|
{row.unitPriceFen > 0 ? (
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
|
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
|
||||||
@@ -387,37 +578,28 @@ export default function ProductRulesPanel() {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: '匹配',
|
|
||||||
dataIndex: 'matchType',
|
|
||||||
width: '9%',
|
|
||||||
render: (value) => (
|
|
||||||
<Tag color={value === 'exact' ? 'blue' : 'orange'} style={{ margin: 0 }}>
|
|
||||||
{value === 'exact' ? '精确' : '包含'}
|
|
||||||
</Tag>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
key: 'status',
|
key: 'status',
|
||||||
width: '7%',
|
width: '14%',
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Space direction="vertical" size={2}>
|
<Space direction="vertical" size={2}>
|
||||||
<Switch
|
<Switch
|
||||||
size="small"
|
size="small"
|
||||||
checked={row.enabled}
|
checked={row.enabled}
|
||||||
checkedChildren="启用"
|
checkedChildren="开"
|
||||||
unCheckedChildren="停用"
|
unCheckedChildren="关"
|
||||||
onChange={(checked) => toggleRuleEnabled(row, checked)}
|
onChange={(checked) => toggleRuleEnabled(row, checked)}
|
||||||
|
onClick={(_, e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
<Space size={2} wrap>
|
<Space size={2} wrap>
|
||||||
{row.autoCreate ? (
|
{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>
|
</Tag>
|
||||||
) : null}
|
) : null}
|
||||||
{row.sharing?.enabled ? (
|
{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>
|
</Tag>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -428,12 +610,13 @@ export default function ProductRulesPanel() {
|
|||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
width: '6%',
|
width: '10%',
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Space size={0} onClick={(event) => event.stopPropagation()}>
|
<Space size={0} onClick={(event) => event.stopPropagation()}>
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
icon={<EditOutlined />}
|
icon={<EditOutlined />}
|
||||||
|
size="small"
|
||||||
aria-label={`编辑规则 ${row.ruleKey}`}
|
aria-label={`编辑规则 ${row.ruleKey}`}
|
||||||
title="编辑规则"
|
title="编辑规则"
|
||||||
onClick={() => editRule(row)}
|
onClick={() => editRule(row)}
|
||||||
@@ -449,6 +632,7 @@ export default function ProductRulesPanel() {
|
|||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
danger
|
danger
|
||||||
|
size="small"
|
||||||
icon={<DeleteOutlined />}
|
icon={<DeleteOutlined />}
|
||||||
aria-label={`删除规则 ${row.ruleKey}`}
|
aria-label={`删除规则 ${row.ruleKey}`}
|
||||||
title="删除规则"
|
title="删除规则"
|
||||||
@@ -462,24 +646,28 @@ export default function ProductRulesPanel() {
|
|||||||
return (
|
return (
|
||||||
<section className="platform-panel-stack">
|
<section className="platform-panel-stack">
|
||||||
<div className="product-rules-layout">
|
<div className="product-rules-layout">
|
||||||
{/* Left Form Card - 30% Width Proportional Split */}
|
{/* Left Form Card - 60% Width Split */}
|
||||||
<Card
|
<Card
|
||||||
className="product-rules-form-card"
|
className="product-rules-form-card"
|
||||||
title={
|
title={
|
||||||
<Space size={6}>
|
<Space size={8}>
|
||||||
<FormOutlined style={{ color: '#1677ff' }} />
|
<FormOutlined style={{ color: '#1677ff' }} />
|
||||||
<span>{editingRule ? `编辑模板 · ${editingRule.ruleKey}` : '新建接单模板'}</span>
|
<Typography.Text strong style={{ fontSize: 15 }}>
|
||||||
|
{editingRule
|
||||||
|
? `编辑模板 · ${editingRule.productName || editingRule.ruleKey}`
|
||||||
|
: '新建接单模板'}
|
||||||
|
</Typography.Text>
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
extra={
|
extra={
|
||||||
<Space size={4}>
|
<Space size={8} align="center">
|
||||||
{editingRule ? (
|
{editingRule ? (
|
||||||
<Button type="link" size="small" onClick={resetRuleForm}>
|
<Button size="middle" onClick={resetRuleForm}>
|
||||||
取消编辑
|
+ 新建模板
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Button type="primary" htmlType="submit" form="product-rule-form">
|
<Button type="primary" size="middle" htmlType="submit" form="product-rule-form">
|
||||||
{editingRule ? '保存修改' : '创建规则'}
|
{editingRule ? '保存模板' : '创建模板'}
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
@@ -489,7 +677,7 @@ export default function ProductRulesPanel() {
|
|||||||
id="product-rule-form"
|
id="product-rule-form"
|
||||||
form={form}
|
form={form}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
className="worker-rule-form-30"
|
className="product-rule-main-form"
|
||||||
initialValues={{
|
initialValues={{
|
||||||
platform: 'kuaishou',
|
platform: 'kuaishou',
|
||||||
matchType: 'contains',
|
matchType: 'contains',
|
||||||
@@ -505,15 +693,8 @@ export default function ProductRulesPanel() {
|
|||||||
}}
|
}}
|
||||||
onFinish={saveRule}
|
onFinish={saveRule}
|
||||||
>
|
>
|
||||||
<div className="rule-form-grid-2col">
|
{/* 基础信息 */}
|
||||||
<Form.Item label="来源" name="provider">
|
<div className="form-grid-3col">
|
||||||
<Input placeholder="如 91kaquan" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item label="平台" name="platform">
|
|
||||||
<Input placeholder="kuaishou" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="模板名称"
|
label="模板名称"
|
||||||
name="productName"
|
name="productName"
|
||||||
@@ -534,7 +715,10 @@ export default function ProductRulesPanel() {
|
|||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: 计价方式 + 单价/金额 + 任务时限 */}
|
||||||
|
<div className="form-grid-3col">
|
||||||
<Form.Item label="计价方式" name="pricingMode">
|
<Form.Item label="计价方式" name="pricingMode">
|
||||||
<Radio.Group
|
<Radio.Group
|
||||||
optionType="button"
|
optionType="button"
|
||||||
@@ -551,7 +735,7 @@ export default function ProductRulesPanel() {
|
|||||||
{({ getFieldValue }) =>
|
{({ getFieldValue }) =>
|
||||||
getFieldValue('pricingMode') === 'unit' ? (
|
getFieldValue('pricingMode') === 'unit' ? (
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="单价"
|
label="按件单价"
|
||||||
name="unitPrice"
|
name="unitPrice"
|
||||||
rules={[{ required: true, message: '请填写单价' }]}
|
rules={[{ required: true, message: '请填写单价' }]}
|
||||||
>
|
>
|
||||||
@@ -565,14 +749,13 @@ export default function ProductRulesPanel() {
|
|||||||
}
|
}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item label="排序" name="sortOrder">
|
|
||||||
<InputNumber min={0} max={9999} className="full-width" />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
<Form.Item label="任务时限" name="timeoutMinutes">
|
<Form.Item label="任务时限" name="timeoutMinutes">
|
||||||
<InputNumber min={0} step={5} addonAfter="分钟" className="full-width" />
|
<InputNumber min={0} step={5} addonAfter="分钟" className="full-width" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 3: 超时策略 + 来源 + 平台 */}
|
||||||
|
<div className="form-grid-3col">
|
||||||
<Form.Item label="超时策略" name="timeoutPolicy">
|
<Form.Item label="超时策略" name="timeoutPolicy">
|
||||||
<Select
|
<Select
|
||||||
options={[
|
options={[
|
||||||
@@ -582,128 +765,168 @@ export default function ProductRulesPanel() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="来源" name="provider">
|
||||||
|
<Input placeholder="如 91kaquan" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="平台" name="platform">
|
||||||
|
<Input placeholder="如 kuaishou" />
|
||||||
|
</Form.Item>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-switches-strip">
|
{/* Row 4: 排序权重 + 功能开关与拼单行内参数 */}
|
||||||
<Form.Item label="启用" name="enabled" valuePropName="checked">
|
<div className="form-grid-3col">
|
||||||
<Switch size="small" />
|
<Form.Item label="排序权重" name="sortOrder">
|
||||||
|
<InputNumber min={0} max={9999} placeholder="0" className="full-width" />
|
||||||
</Form.Item>
|
</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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Form.Item noStyle shouldUpdate>
|
<Form.Item noStyle shouldUpdate>
|
||||||
{({ getFieldValue }) => {
|
{({ getFieldValue }) => {
|
||||||
const sharingEnabled = getFieldValue('sharingEnabled')
|
const sharingEnabled = getFieldValue('sharingEnabled')
|
||||||
const unitMode = getFieldValue('pricingMode') === 'unit'
|
const unitMode = getFieldValue('pricingMode') === 'unit'
|
||||||
if (!sharingEnabled) return null
|
|
||||||
|
|
||||||
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" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label="拼单单价(可选)" name="sharingUnitReward">
|
|
||||||
<InputNumber min={0.01} step={1} addonAfter="元" className="full-width" />
|
|
||||||
</Form.Item>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sharing-box-inline">
|
<div className="switches-inline-group span-2">
|
||||||
<Form.Item
|
<Form.Item label="启用模板" name="enabled" valuePropName="checked">
|
||||||
label="拼单数量"
|
<Switch size="small" />
|
||||||
name="sharingTotalQuantity"
|
</Form.Item>
|
||||||
rules={[{ required: true, message: '必填' }]}
|
<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
|
<InputNumber
|
||||||
|
size="small"
|
||||||
min={1}
|
min={1}
|
||||||
max={100000}
|
placeholder="拼单数量"
|
||||||
addonAfter="份"
|
addonAfter="份"
|
||||||
className="full-width"
|
style={{ width: 115 }}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
|
!unitMode &&
|
||||||
syncSharingRuleForm({ sharingTotalQuantity: Number(value) || 0 })
|
syncSharingRuleForm({ sharingTotalQuantity: Number(value) || 0 })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item name="sharingUnitReward" style={{ margin: 0 }}>
|
||||||
label="拼单单价"
|
|
||||||
name="sharingUnitReward"
|
|
||||||
rules={[{ required: true, message: '必填' }]}
|
|
||||||
>
|
|
||||||
<InputNumber
|
<InputNumber
|
||||||
|
size="small"
|
||||||
min={0.01}
|
min={0.01}
|
||||||
step={1}
|
step={1}
|
||||||
|
placeholder="拼单单价"
|
||||||
addonAfter="元"
|
addonAfter="元"
|
||||||
className="full-width"
|
style={{ width: 115 }}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
|
!unitMode &&
|
||||||
syncSharingRuleForm({ sharingUnitReward: Number(value) || 0 })
|
syncSharingRuleForm({ sharingUnitReward: Number(value) || 0 })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
{!unitMode ? (
|
||||||
label="拼单总价"
|
<Form.Item name="sharingTotalAmount" style={{ margin: 0 }}>
|
||||||
name="sharingTotalAmount"
|
|
||||||
rules={[{ required: true, message: '必填' }]}
|
|
||||||
>
|
|
||||||
<InputNumber
|
<InputNumber
|
||||||
|
size="small"
|
||||||
min={0.01}
|
min={0.01}
|
||||||
step={1}
|
step={1}
|
||||||
|
placeholder="拼单总价"
|
||||||
addonAfter="元"
|
addonAfter="元"
|
||||||
className="full-width"
|
style={{ width: 115 }}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
syncSharingRuleForm({ sharingTotalAmount: Number(value) || 0 })
|
syncSharingRuleForm({ sharingTotalAmount: Number(value) || 0 })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 资料字段: 默认展开 4 行全部清晰可见,无内嵌滚动条 */}
|
{/* 资料字段 */}
|
||||||
<Form.Item
|
<Form.Item
|
||||||
label="资料字段"
|
label="资料字段(工单要求打手提交的资料)"
|
||||||
name="fieldsText"
|
name="fieldsText"
|
||||||
style={{ marginBottom: 0 }}
|
extra="格式:key:名称#选项1,选项2(每行一个字段)"
|
||||||
extra="格式:key:名称#选项1,选项2"
|
|
||||||
>
|
>
|
||||||
<Input.TextArea
|
<Input.TextArea
|
||||||
rows={4}
|
rows={2}
|
||||||
placeholder="gameId:游戏编号 gameNickname:游戏昵称 system:系统#安卓,苹果 serverZone:区服#QQ区,微信区"
|
placeholder={
|
||||||
|
'gameId:游戏编号\ngameNickname:游戏昵称\nsystem:系统#安卓,苹果\nserverZone:区服#QQ区,微信区'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</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>
|
</Card>
|
||||||
|
|
||||||
{/* Right Table Card - 60% Width, Zero Horizontal Scrollbar */}
|
{/* Right Table Card - 40% Width */}
|
||||||
<Card
|
<Card
|
||||||
className="product-rules-list-card"
|
className="product-rules-list-card"
|
||||||
title={
|
title={
|
||||||
<Space align="center" size={10}>
|
<Space align="center" size={8}>
|
||||||
<Typography.Text strong style={{ fontSize: 15 }}>
|
<Typography.Text strong style={{ fontSize: 15 }}>
|
||||||
接单模板列表
|
接单模板列表
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
<Tag color="blue" style={{ margin: 0 }}>
|
<Tag color="blue" style={{ margin: 0 }}>
|
||||||
{rulesPagination?.total || 0} 条规则
|
{rulesPagination?.total || 0}
|
||||||
</Tag>
|
</Tag>
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
extra={
|
extra={
|
||||||
<Space size={8} wrap>
|
<Space size={6} wrap className="rules-list-card-extra">
|
||||||
<Input.Search
|
<Input.Search
|
||||||
allowClear
|
allowClear
|
||||||
placeholder="搜索模板/商品/SKU/店铺"
|
placeholder="搜索模板/商品/SKU"
|
||||||
style={{ width: 200 }}
|
className="search-input-fluid"
|
||||||
value={keywordInput}
|
value={keywordInput}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const value = event.target.value
|
const value = event.target.value
|
||||||
@@ -714,14 +937,15 @@ export default function ProductRulesPanel() {
|
|||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
icon={<ReloadOutlined />}
|
icon={<ReloadOutlined />}
|
||||||
|
size="middle"
|
||||||
loading={rulesQuery.isFetching}
|
loading={rulesQuery.isFetching}
|
||||||
onClick={() => rulesQuery.refetch()}
|
onClick={() => rulesQuery.refetch()}
|
||||||
>
|
/>
|
||||||
刷新
|
<Button size="middle" onClick={reprocessKuaishouOrders}>
|
||||||
|
重匹配
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={reprocessKuaishouOrders}>重匹配快手订单</Button>
|
<Button type="primary" size="middle" icon={<PlusOutlined />} onClick={resetRuleForm}>
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={resetRuleForm}>
|
新建
|
||||||
新建规则
|
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
@@ -731,6 +955,7 @@ export default function ProductRulesPanel() {
|
|||||||
<div className="product-rules-category-tabs-bar">
|
<div className="product-rules-category-tabs-bar">
|
||||||
<Tabs
|
<Tabs
|
||||||
activeKey={categoryId ? String(categoryId) : 'all'}
|
activeKey={categoryId ? String(categoryId) : 'all'}
|
||||||
|
size="small"
|
||||||
onChange={(key) => {
|
onChange={(key) => {
|
||||||
setCategoryId(key === 'all' ? undefined : Number(key))
|
setCategoryId(key === 'all' ? undefined : Number(key))
|
||||||
setPage(1)
|
setPage(1)
|
||||||
@@ -750,6 +975,7 @@ export default function ProductRulesPanel() {
|
|||||||
|
|
||||||
<Table<WorkProductRule>
|
<Table<WorkProductRule>
|
||||||
rowKey="ruleId"
|
rowKey="ruleId"
|
||||||
|
size="small"
|
||||||
loading={rulesQuery.isLoading || mappingsQuery.isLoading}
|
loading={rulesQuery.isLoading || mappingsQuery.isLoading}
|
||||||
dataSource={rules}
|
dataSource={rules}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
@@ -764,7 +990,8 @@ export default function ProductRulesPanel() {
|
|||||||
}}
|
}}
|
||||||
onRow={(row) => ({
|
onRow={(row) => ({
|
||||||
onClick: () => editRule(row),
|
onClick: () => editRule(row),
|
||||||
title: '点击编辑此规则',
|
style: { cursor: 'pointer' },
|
||||||
|
title: '点击在左侧编辑此模板',
|
||||||
})}
|
})}
|
||||||
pagination={buildAdminTablePagination({
|
pagination={buildAdminTablePagination({
|
||||||
current: rulesPagination?.page || page,
|
current: rulesPagination?.page || page,
|
||||||
@@ -797,3 +1024,21 @@ function formatRuleFields(fields: CollectField[]) {
|
|||||||
function hasManualSharingInput(quantity: unknown, unitRewardFen: unknown) {
|
function hasManualSharingInput(quantity: unknown, unitRewardFen: unknown) {
|
||||||
return Number(quantity || 0) > 1 || Number(unitRewardFen || 0) > 0
|
return Number(quantity || 0) > 1 || Number(unitRewardFen || 0) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createShopNameBySellerId(shops: AdminKuaishouIndustryShopOption[]) {
|
||||||
|
const names = new Map<string, string>()
|
||||||
|
for (const shop of shops) {
|
||||||
|
const name = String(shop.shopName || '').trim()
|
||||||
|
if (!name) continue
|
||||||
|
const sellerId = String(shop.sellerId || '').trim()
|
||||||
|
const shopId = String(shop.shopId || '').trim()
|
||||||
|
if (sellerId) names.set(sellerId, name)
|
||||||
|
if (shopId) names.set(shopId, name)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatShopLabel(sellerId: string, shopNameBySellerId: Map<string, string>) {
|
||||||
|
const name = shopNameBySellerId.get(sellerId)
|
||||||
|
return name ? `${name}(${sellerId})` : sellerId
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,438 @@
|
|||||||
|
import {
|
||||||
|
App,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Checkbox,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
Popconfirm,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Switch,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
} from 'antd'
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import {
|
||||||
|
CheckOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
LinkOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
ShopOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
|
||||||
|
import { saveAdminWorkProductRuleMapping } from '@/services/admin'
|
||||||
|
import type { WorkProductRuleMapping } from '@/types/worker-platform'
|
||||||
|
import type { AdminKuaishouIndustryShopOption } from '@/types/admin'
|
||||||
|
|
||||||
|
type MappingFormValues = {
|
||||||
|
mappingId?: number
|
||||||
|
ruleId?: number
|
||||||
|
sellerIds: string[]
|
||||||
|
productScope: 'all_products' | 'selected_products'
|
||||||
|
relItemId?: string
|
||||||
|
itemTitle?: string
|
||||||
|
relSkuId?: string
|
||||||
|
skuNick?: string
|
||||||
|
skuMatchMode: 'fuzzy' | 'exact'
|
||||||
|
mappingType: 'product_default' | 'sku_exact' | 'sku_series'
|
||||||
|
enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SourcePrefill = {
|
||||||
|
sellerId: string
|
||||||
|
relItemId: string
|
||||||
|
itemTitle: string
|
||||||
|
relSkuId: string
|
||||||
|
skuNick: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkProductBindingFormProps = {
|
||||||
|
ruleId: number
|
||||||
|
ruleName?: string
|
||||||
|
shops: AdminKuaishouIndustryShopOption[]
|
||||||
|
mappings: WorkProductRuleMapping[]
|
||||||
|
activeMappingId?: number | 'new' | null
|
||||||
|
sourcePrefill?: SourcePrefill | null
|
||||||
|
onSelectMapping: (mappingId: number | 'new') => void
|
||||||
|
onDeleteMapping?: (mapping: WorkProductRuleMapping) => void
|
||||||
|
onSaved: (savedMappingId?: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkProductBindingForm({
|
||||||
|
ruleId,
|
||||||
|
ruleName,
|
||||||
|
shops,
|
||||||
|
mappings,
|
||||||
|
activeMappingId,
|
||||||
|
sourcePrefill,
|
||||||
|
onSelectMapping,
|
||||||
|
onDeleteMapping,
|
||||||
|
onSaved,
|
||||||
|
}: WorkProductBindingFormProps) {
|
||||||
|
const { message } = App.useApp()
|
||||||
|
const [form] = Form.useForm<MappingFormValues>()
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const shopNameBySellerId = useMemo(() => createShopNameBySellerId(shops), [shops])
|
||||||
|
const sellerOptions = useMemo(() => {
|
||||||
|
const knownSellerIds = shops.map((shop) => String(shop.sellerId || '').trim()).filter(Boolean)
|
||||||
|
const prefillSellerIds = sourcePrefill
|
||||||
|
? [String(sourcePrefill.sellerId || '').trim()].filter(Boolean)
|
||||||
|
: []
|
||||||
|
const values = Array.from(new Set([...knownSellerIds, ...prefillSellerIds]))
|
||||||
|
.sort((left, right) => left.localeCompare(right))
|
||||||
|
.map((sellerId) => ({
|
||||||
|
value: sellerId,
|
||||||
|
label: formatShopLabel(sellerId, shopNameBySellerId),
|
||||||
|
}))
|
||||||
|
return values
|
||||||
|
}, [shops, shopNameBySellerId, sourcePrefill])
|
||||||
|
|
||||||
|
const currentMapping = useMemo(() => {
|
||||||
|
if (activeMappingId === 'new') return null
|
||||||
|
if (typeof activeMappingId === 'number') {
|
||||||
|
return mappings.find((m) => m.mappingId === activeMappingId) || null
|
||||||
|
}
|
||||||
|
return mappings[0] || null
|
||||||
|
}, [activeMappingId, mappings])
|
||||||
|
|
||||||
|
const isNew = activeMappingId === 'new' || (!currentMapping && mappings.length === 0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentMapping) {
|
||||||
|
form.setFieldsValue({
|
||||||
|
mappingId: currentMapping.mappingId,
|
||||||
|
ruleId: currentMapping.ruleId,
|
||||||
|
sellerIds: currentMapping.sellerIds,
|
||||||
|
productScope:
|
||||||
|
currentMapping.relItemId || currentMapping.itemTitle
|
||||||
|
? 'selected_products'
|
||||||
|
: 'all_products',
|
||||||
|
relItemId: currentMapping.relItemId,
|
||||||
|
itemTitle: currentMapping.itemTitle,
|
||||||
|
relSkuId: currentMapping.relSkuId,
|
||||||
|
skuNick: currentMapping.skuNick,
|
||||||
|
skuMatchMode: currentMapping.skuMatchMode,
|
||||||
|
mappingType: currentMapping.mappingType,
|
||||||
|
enabled: currentMapping.enabled,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
form.resetFields()
|
||||||
|
form.setFieldsValue({
|
||||||
|
ruleId,
|
||||||
|
mappingType: 'product_default',
|
||||||
|
productScope: sourcePrefill?.relItemId ? 'selected_products' : 'all_products',
|
||||||
|
skuMatchMode: 'fuzzy',
|
||||||
|
enabled: true,
|
||||||
|
sellerIds: sourcePrefill?.sellerId
|
||||||
|
? [sourcePrefill.sellerId].filter(Boolean)
|
||||||
|
: sellerOptions.map((opt) => opt.value),
|
||||||
|
relItemId: sourcePrefill?.relItemId || '',
|
||||||
|
itemTitle: sourcePrefill?.itemTitle || '',
|
||||||
|
relSkuId: sourcePrefill?.relSkuId || '',
|
||||||
|
skuNick: sourcePrefill?.skuNick || '',
|
||||||
|
})
|
||||||
|
}, [currentMapping, form, isNew, ruleId, sellerOptions, sourcePrefill])
|
||||||
|
|
||||||
|
function handleValuesChange(changedValues: Partial<MappingFormValues>) {
|
||||||
|
if (changedValues.mappingType) {
|
||||||
|
const isProductDefault = changedValues.mappingType === 'product_default'
|
||||||
|
form.setFieldsValue({
|
||||||
|
productScope: isProductDefault ? 'selected_products' : 'all_products',
|
||||||
|
...(isProductDefault ? {} : { relItemId: '', itemTitle: '' }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (changedValues.productScope === 'all_products') {
|
||||||
|
form.setFieldsValue({ relItemId: '', itemTitle: '' })
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
changedValues.sellerIds &&
|
||||||
|
changedValues.sellerIds.length > 1 &&
|
||||||
|
form.getFieldValue('mappingType') === 'product_default'
|
||||||
|
) {
|
||||||
|
form.setFieldValue('relItemId', '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields()
|
||||||
|
const { productScope: _productScope, ...payload } = values
|
||||||
|
const targetRuleId = ruleId || payload.ruleId
|
||||||
|
if (!targetRuleId) {
|
||||||
|
message.error('请选择接单模板')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
const res = await saveAdminWorkProductRuleMapping({ ...payload, ruleId: targetRuleId })
|
||||||
|
message.success(values.mappingId ? '商品绑定已更新' : '商品绑定已创建')
|
||||||
|
onSaved(res.data?.mapping?.mappingId)
|
||||||
|
} catch (error) {
|
||||||
|
if (error && typeof error === 'object' && 'errorFields' in error) return
|
||||||
|
message.error(error instanceof Error ? error.message : '保存商品绑定失败')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
className="binding-editor-card"
|
||||||
|
title={
|
||||||
|
<div className="binding-editor-card-header">
|
||||||
|
<Space size={8} wrap className="binding-pills-row">
|
||||||
|
<LinkOutlined style={{ color: '#1677ff' }} />
|
||||||
|
<Typography.Text strong style={{ fontSize: 13 }}>
|
||||||
|
商品匹配规则:
|
||||||
|
</Typography.Text>
|
||||||
|
{mappings.map((m, index) => {
|
||||||
|
const isSelected =
|
||||||
|
!isNew &&
|
||||||
|
(currentMapping?.mappingId === m.mappingId || (index === 0 && !currentMapping))
|
||||||
|
const label =
|
||||||
|
m.mappingType === 'product_default'
|
||||||
|
? m.itemTitle || m.relItemId
|
||||||
|
? '指定商品'
|
||||||
|
: '全部商品 (默认)'
|
||||||
|
: m.skuNick ||
|
||||||
|
m.relSkuId ||
|
||||||
|
(m.mappingType === 'sku_series' ? 'SKU数量系列' : 'SKU精确覆盖')
|
||||||
|
return (
|
||||||
|
<Tag.CheckableTag
|
||||||
|
key={m.mappingId}
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => onSelectMapping(m.mappingId)}
|
||||||
|
className={`binding-pill-tag ${isSelected ? 'active' : ''}`}
|
||||||
|
>
|
||||||
|
#{index + 1} {label}
|
||||||
|
</Tag.CheckableTag>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type={isNew ? 'primary' : 'dashed'}
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => onSelectMapping('new')}
|
||||||
|
className="binding-add-btn"
|
||||||
|
>
|
||||||
|
添加新规则
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Space size={8}>
|
||||||
|
{currentMapping && !isNew && onDeleteMapping ? (
|
||||||
|
<Popconfirm
|
||||||
|
title="确定删除此商品绑定规则?"
|
||||||
|
description="删除后,快手订单将不再按此规则匹配到当前模板。"
|
||||||
|
okText="删除"
|
||||||
|
cancelText="取消"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
onConfirm={() => onDeleteMapping(currentMapping)}
|
||||||
|
>
|
||||||
|
<Button size="small" type="text" danger icon={<DeleteOutlined />}>
|
||||||
|
删除此规则
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
icon={<CheckOutlined />}
|
||||||
|
loading={saving}
|
||||||
|
onClick={submit}
|
||||||
|
>
|
||||||
|
{isNew ? '创建商品绑定' : '保存商品绑定'}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onValuesChange={handleValuesChange}
|
||||||
|
className="binding-form-content"
|
||||||
|
>
|
||||||
|
<Form.Item name="mappingId" hidden>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="ruleId" hidden>
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* 覆盖店铺 */}
|
||||||
|
<Form.Item
|
||||||
|
label={
|
||||||
|
<Space size={4}>
|
||||||
|
<ShopOutlined />
|
||||||
|
<span>覆盖店铺</span>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
name="sellerIds"
|
||||||
|
rules={[{ required: true, message: '至少选择一个店铺' }]}
|
||||||
|
>
|
||||||
|
<ShopSelector options={sellerOptions} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{/* 绑定类型 & 商品范围 & 状态 */}
|
||||||
|
<div className="form-grid-3col">
|
||||||
|
<Form.Item label="绑定类型" name="mappingType">
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ value: 'product_default', label: '商品默认模板' },
|
||||||
|
{ value: 'sku_series', label: 'SKU 数量系列' },
|
||||||
|
{ value: 'sku_exact', label: 'SKU 精确覆盖' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item noStyle shouldUpdate>
|
||||||
|
{({ getFieldValue }) => {
|
||||||
|
const mappingType = getFieldValue('mappingType')
|
||||||
|
const isProductDefault = mappingType === 'product_default'
|
||||||
|
return !isProductDefault ? (
|
||||||
|
<Form.Item label="商品范围" name="productScope">
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ value: 'all_products', label: '全部商品' },
|
||||||
|
{ value: 'selected_products', label: '指定商品' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
) : (
|
||||||
|
<Form.Item label="快手大标题" name="itemTitle">
|
||||||
|
<Input placeholder="商品大标题或核心关键词" />
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="规则状态" name="enabled" valuePropName="checked">
|
||||||
|
<Switch checkedChildren="已启用" unCheckedChildren="已停用" />
|
||||||
|
</Form.Item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 商品ID / 大标题 / SKU 条件 */}
|
||||||
|
<Form.Item noStyle shouldUpdate>
|
||||||
|
{({ getFieldValue }) => {
|
||||||
|
const mappingType = getFieldValue('mappingType')
|
||||||
|
const isProductDefault = mappingType === 'product_default'
|
||||||
|
const productScope = getFieldValue('productScope')
|
||||||
|
const showProductScope = isProductDefault || productScope === 'selected_products'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="form-grid-3col">
|
||||||
|
{showProductScope ? (
|
||||||
|
<Form.Item
|
||||||
|
label="关联商品 ID"
|
||||||
|
name="relItemId"
|
||||||
|
extra="支持多个商品ID,逗号/空格分隔"
|
||||||
|
>
|
||||||
|
<Input placeholder="如 123456789, 987654321" />
|
||||||
|
</Form.Item>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isProductDefault && showProductScope ? (
|
||||||
|
<Form.Item label="快手大标题" name="itemTitle" extra="可选,父商品分流">
|
||||||
|
<Input placeholder="可选,商品大标题关键字" />
|
||||||
|
</Form.Item>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{mappingType === 'sku_exact' ? (
|
||||||
|
<>
|
||||||
|
<Form.Item label="关联 SKU ID" name="relSkuId">
|
||||||
|
<Input placeholder="ext.relSkuId,0 自动忽略" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="具体 SKU 名称" name="skuNick">
|
||||||
|
<Input placeholder="ext.skuNick,完整精确匹配" />
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
) : mappingType === 'sku_series' ? (
|
||||||
|
<>
|
||||||
|
<Form.Item label="SKU 系列名称" name="skuNick">
|
||||||
|
<Input placeholder="例如:指挥官密钥;自动覆盖 1个、10个等规格" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="SKU 匹配方式" name="skuMatchMode">
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ value: 'fuzzy', label: '模糊匹配(兼容密钥/秘钥)' },
|
||||||
|
{ value: 'exact', label: '精确匹配' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShopSelector({
|
||||||
|
value = [],
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
value?: string[]
|
||||||
|
onChange?: (nextValue: string[]) => void
|
||||||
|
options: Array<{ value: string; label: string }>
|
||||||
|
}) {
|
||||||
|
const optionValues = options.map((option) => option.value)
|
||||||
|
const selectedValues = Array.isArray(value) ? value.map(String) : []
|
||||||
|
const selectedOptionValues = selectedValues.filter((sellerId) => optionValues.includes(sellerId))
|
||||||
|
const otherValues = selectedValues.filter((sellerId) => !optionValues.includes(sellerId))
|
||||||
|
const allSelected = optionValues.length > 0 && selectedOptionValues.length === optionValues.length
|
||||||
|
const partiallySelected = selectedOptionValues.length > 0 && !allSelected
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shop-selector-box-inline">
|
||||||
|
<Checkbox
|
||||||
|
checked={allSelected}
|
||||||
|
disabled={optionValues.length === 0}
|
||||||
|
indeterminate={partiallySelected}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange?.(event.target.checked ? [...otherValues, ...optionValues] : otherValues)
|
||||||
|
}
|
||||||
|
className="shop-all-toggle"
|
||||||
|
>
|
||||||
|
全选
|
||||||
|
</Checkbox>
|
||||||
|
<Checkbox.Group
|
||||||
|
value={selectedValues}
|
||||||
|
onChange={(nextValues) => onChange?.(nextValues.map(String))}
|
||||||
|
className="shop-checkbox-inline-wrap"
|
||||||
|
>
|
||||||
|
{options.map((option) => (
|
||||||
|
<Checkbox key={option.value} value={option.value} className="shop-chk-item">
|
||||||
|
{option.label}
|
||||||
|
</Checkbox>
|
||||||
|
))}
|
||||||
|
</Checkbox.Group>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createShopNameBySellerId(shops: AdminKuaishouIndustryShopOption[]) {
|
||||||
|
const names = new Map<string, string>()
|
||||||
|
for (const shop of shops) {
|
||||||
|
const name = String(shop.shopName || '').trim()
|
||||||
|
if (!name) continue
|
||||||
|
const sellerId = String(shop.sellerId || '').trim()
|
||||||
|
const shopId = String(shop.shopId || '').trim()
|
||||||
|
if (sellerId) names.set(sellerId, name)
|
||||||
|
if (shopId) names.set(shopId, name)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatShopLabel(sellerId: string, shopNameBySellerId: Map<string, string>) {
|
||||||
|
const name = shopNameBySellerId.get(sellerId)
|
||||||
|
return name ? `${name}(${sellerId})` : sellerId
|
||||||
|
}
|
||||||
@@ -140,10 +140,11 @@ export function deleteAdminWorkProductRule(ruleId: number) {
|
|||||||
return apiDelete<{ deleted: boolean }>(`/api/v1/admin/worker-platform/product-rules/${ruleId}`)
|
return apiDelete<{ deleted: boolean }>(`/api/v1/admin/worker-platform/product-rules/${ruleId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchAdminWorkProductRuleMappings() {
|
export function fetchAdminWorkProductRuleMappings(params?: Record<string, unknown>) {
|
||||||
return apiGet<{ items: WorkProductRuleMapping[] }>(
|
return apiGet<{
|
||||||
'/api/v1/admin/worker-platform/product-mappings',
|
items: WorkProductRuleMapping[]
|
||||||
)
|
pagination?: { page: number; pageSize: number; total: number }
|
||||||
|
}>('/api/v1/admin/worker-platform/product-mappings', params)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveAdminWorkProductRuleMapping(payload: {
|
export function saveAdminWorkProductRuleMapping(payload: {
|
||||||
|
|||||||
@@ -1052,120 +1052,257 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== 物品规则配置:3:7 比例响应式双栏 ===== */
|
/* ===== 物品规则配置:6:4 比例清晰现代化双栏 ===== */
|
||||||
|
|
||||||
.product-rules-layout {
|
.product-rules-layout {
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 6fr) minmax(0, 4fr);
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
align-items: flex-start;
|
align-items: start;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-rules-form-card {
|
.product-rules-form-card {
|
||||||
flex: 0 0 calc(30% - 8px);
|
width: 100%;
|
||||||
width: calc(30% - 8px);
|
min-width: 0;
|
||||||
min-width: 360px;
|
|
||||||
border-radius: 8px;
|
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 {
|
.product-rules-form-card .ant-card-head {
|
||||||
width: 5px;
|
padding: 0 16px;
|
||||||
}
|
min-height: 48px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
.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-body {
|
.product-rules-form-card .ant-card-body {
|
||||||
padding: 12px 14px;
|
padding: 16px;
|
||||||
overflow: visible;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.worker-rule-form {
|
.product-rule-main-form .ant-form-item {
|
||||||
width: 100%;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.worker-rule-form-30 .rule-form-grid-2col {
|
.product-rule-main-form .ant-form-item-label > label {
|
||||||
display: grid;
|
font-size: 13px;
|
||||||
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;
|
|
||||||
color: #374151;
|
color: #374151;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.worker-rule-form-30 .form-switches-strip {
|
.form-grid-2col {
|
||||||
display: flex;
|
display: grid;
|
||||||
align-items: center;
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
gap: 16px;
|
gap: 0 14px;
|
||||||
background: #f9fafb;
|
|
||||||
padding: 5px 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin-top: 3px;
|
|
||||||
margin-bottom: 7px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
margin-bottom: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.worker-rule-form-30 .sharing-box-inline {
|
.sharing-inline-inputs {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
background: #f8fafc;
|
padding-left: 12px;
|
||||||
border: 1px dashed #cbd5e1;
|
border-left: 1px solid #cbd5e1;
|
||||||
padding: 6px 8px;
|
margin-left: 4px;
|
||||||
border-radius: 6px;
|
|
||||||
margin-bottom: 7px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
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 {
|
.product-rules-list-card {
|
||||||
flex: 0 0 calc(70% - 8px);
|
width: 100%;
|
||||||
width: calc(70% - 8px);
|
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
border-radius: 8px;
|
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 {
|
.product-rules-list-card .ant-card-body {
|
||||||
padding: 12px 16px;
|
padding: 12px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-rules-category-tabs-bar {
|
.product-rules-category-tabs-bar {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 8px;
|
||||||
border-bottom: 1px solid #f0f0f0;
|
border-bottom: 1px solid #f0f0f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1181,20 +1318,26 @@
|
|||||||
background: #f0fdf4;
|
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 {
|
.product-rules-layout {
|
||||||
flex-direction: column;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.product-rules-form-card,
|
.product-rules-form-card,
|
||||||
.product-rules-list-card {
|
.product-rules-list-card {
|
||||||
flex: 1 1 100%;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.product-rules-form-card {
|
|
||||||
position: static;
|
position: static;
|
||||||
max-height: none;
|
max-height: none;
|
||||||
overflow-y: visible;
|
overflow-y: visible;
|
||||||
|
|||||||
Reference in New Issue
Block a user