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

- 优化接单模板与模板列表 6:4 比例自适应布局
- 适用商品绑定支持左侧直接内联展开编辑,免弹窗模式
- 支持绑定规则多标签快速切换、实时新增与删除
- 基础表单与商品绑定统一采用 3 列网格布局,严格几何对齐
- 「启用拼单」开关及参数行内紧凑展开,移除多余背景框与纵向滚动条
This commit is contained in:
yml2213
2026-08-18 16:55:58 +08:00
parent be6f4f2507
commit 052b99b05f
11 changed files with 1393 additions and 737 deletions
@@ -14,6 +14,7 @@ export async function listWorkProductRuleMappings(
input: {
enabled?: boolean | null
sellerId?: string
ruleId?: number | string | null
} = {},
): Promise<WorkProductRuleMappingRow[]> {
const conditions: string[] = []
@@ -29,6 +30,11 @@ export async function listWorkProductRuleMappings(
params.push(sellerId)
conditions.push(`wprm.seller_ids_json ? $${params.length}`)
}
const ruleId = Number(input.ruleId)
if (Number.isInteger(ruleId) && ruleId > 0) {
params.push(ruleId)
conditions.push(`wprm.rule_id = $${params.length}`)
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
const result = await query<WorkProductRuleMappingRow>(
`${MAPPING_SELECT}
@@ -41,6 +47,61 @@ export async function listWorkProductRuleMappings(
return result.rows
}
export async function listWorkProductRuleMappingsPage(
input: {
page?: number
pageSize?: number
enabled?: boolean | null
keyword?: string
ruleId?: number | string | null
} = {},
): Promise<{ items: WorkProductRuleMappingRow[]; total: number }> {
const page = Math.max(1, Math.floor(Number(input.page) || 1))
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(input.pageSize) || 20)))
const conditions: string[] = []
const params: unknown[] = []
if (input.enabled !== undefined && input.enabled !== null) {
params.push(input.enabled)
conditions.push(`wprm.enabled = $${params.length}`)
}
const ruleId = Number(input.ruleId)
if (Number.isInteger(ruleId) && ruleId > 0) {
params.push(ruleId)
conditions.push(`wprm.rule_id = $${params.length}`)
}
const keyword = String(input.keyword || '').trim()
if (keyword) {
params.push(`%${keyword}%`)
const parameter = `$${params.length}`
conditions.push(`(
wpr.rule_key ILIKE ${parameter}
OR wpr.product_name ILIKE ${parameter}
OR wprm.item_title ILIKE ${parameter}
OR wprm.rel_item_id ILIKE ${parameter}
OR wprm.rel_sku_id ILIKE ${parameter}
OR wprm.sku_nick ILIKE ${parameter}
)`)
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
const totalResult = await query<{ total: number }>(
`SELECT COUNT(*)::int AS total FROM work_product_rule_mappings wprm
INNER JOIN work_product_rules wpr ON wpr.id = wprm.rule_id ${where}`,
params,
)
const offset = (page - 1) * pageSize
const listParams = [...params, pageSize, offset]
const result = await query<WorkProductRuleMappingRow>(
`${MAPPING_SELECT}
${where}
ORDER BY wprm.seller_ids_json::text ASC, wprm.rel_item_id ASC, wprm.item_title ASC,
CASE WHEN wprm.mapping_type = 'product_default' THEN 0 ELSE 1 END ASC,
wprm.id ASC
LIMIT $${listParams.length - 1} OFFSET $${listParams.length}`,
listParams,
)
return { items: result.rows, total: Number(totalResult.rows[0]?.total || 0) }
}
export async function upsertWorkProductRuleMapping(input: {
mappingId?: number | null
ruleId: number
@@ -195,7 +195,7 @@ router.delete(
router.get(
'/worker-platform/product-mappings',
requireAdminRoles(['admin', 'operator', 'support']),
createJsonHandler(() => listAdminWorkProductRuleMappings(), {
createJsonHandler((req) => listAdminWorkProductRuleMappings(req.query), {
successMessage: 'ok',
errorMessage: '读取商品映射失败',
scope: '[admin/worker-platform/product-mappings]',
@@ -36,6 +36,7 @@ import {
listWorkOrderEventsByOrderId,
listWorkProductRules,
listWorkProductRuleMappings,
listWorkProductRuleMappingsPage,
listWorkProductMatchLogs,
listWorkProductRulesPage,
listWorkerLevels,
@@ -283,8 +284,25 @@ export async function reprocessAdminKuaishouSendCodeWorkOrders(payload: JsonObje
return reprocessKuaishouSendCodeWorkOrders(limit)
}
export async function listAdminWorkProductRuleMappings() {
const items = await listWorkProductRuleMappings()
export async function listAdminWorkProductRuleMappings(query: JsonObject = {}) {
const ruleId = normalizeOptionalId(query.ruleId ?? query.rule_id) || 0
const hasPagination = query.page !== undefined || query.pageSize !== undefined || query.keyword
if (hasPagination) {
const page = normalizePage(query.page)
const pageSize = normalizePageSize(query.pageSize)
const keyword = String(query.keyword || '').trim()
const result = await listWorkProductRuleMappingsPage({
page,
pageSize,
keyword,
ruleId,
})
return {
items: result.items.map(mapWorkProductRuleMapping),
pagination: { page, pageSize, total: result.total },
}
}
const items = await listWorkProductRuleMappings({ ruleId })
return { items: items.map(mapWorkProductRuleMapping) }
}
@@ -359,8 +377,20 @@ export async function saveAdminWorkProductRuleMapping(payload: JsonObject = {})
errorCode: 'work_product_mapping_rule_not_found',
})
}
const mappingId = normalizeOptionalId(payload.mappingId ?? payload.mapping_id)
if (normalizeBoolean(payload.enabled, true)) {
await assertNoConflictingWorkProductRuleMapping({
mappingId,
sellerIds,
relItemId,
itemTitle,
relSkuId,
skuNick,
mappingType,
})
}
const mapping = await upsertWorkProductRuleMapping({
mappingId: normalizeOptionalId(payload.mappingId ?? payload.mapping_id),
mappingId,
ruleId,
sellerIds,
relItemId,
@@ -547,6 +577,10 @@ function normalizeKuaishouMatchId(value: unknown) {
return normalized === '0' ? '' : normalized
}
function hasOwnPayload(payload: JsonObject, key: string) {
return Object.prototype.hasOwnProperty.call(payload, key)
}
function normalizeKuaishouMatchIds(value: unknown) {
const values = Array.isArray(value)
? value
@@ -580,6 +614,92 @@ function normalizeMatchTextForAdmin(value: unknown) {
.trim()
}
function assertNoConflictingWorkProductRuleMapping(input: {
mappingId: number | null
sellerIds: string[]
relItemId: string
itemTitle: string
relSkuId: string
skuNick: string
mappingType: 'product_default' | 'sku_exact' | 'sku_series'
}) {
return listWorkProductRuleMappings({ enabled: true }).then((mappings) => {
const conflict = mappings.find((mapping) => {
if (input.mappingId && Number(mapping.id) === input.mappingId) return false
const existingType =
mapping.mapping_type === 'sku_series'
? 'sku_series'
: mapping.mapping_type === 'sku_exact' || mapping.mapping_type === 'sku_override'
? 'sku_exact'
: 'product_default'
if (existingType !== input.mappingType) return false
const existingSellerIds = parseKuaishouMappingSellerIds(mapping.seller_ids_json)
if (!existingSellerIds.some((sellerId) => input.sellerIds.includes(sellerId))) return false
const inputItemIds = normalizeKuaishouMatchIdList(input.relItemId)
const existingItemIds = normalizeKuaishouMatchIdList(mapping.rel_item_id)
if (
!mappingProductScopesOverlap(
inputItemIds,
input.itemTitle,
existingItemIds,
mapping.item_title,
)
) {
return false
}
if (input.mappingType === 'product_default') return true
if (input.mappingType === 'sku_series') {
// 精确系列是模糊系列的子集,两者并存仍可能产生同分冲突,因此统一拦截。
return (
normalizeMatchTextForAdmin(input.skuNick) === normalizeMatchTextForAdmin(mapping.sku_nick)
)
}
const inputSkuId = normalizeKuaishouMatchId(input.relSkuId)
const existingSkuId = normalizeKuaishouMatchId(mapping.rel_sku_id)
const skuIdConflict = Boolean(inputSkuId && existingSkuId && inputSkuId === existingSkuId)
const skuNameConflict =
Boolean(input.skuNick && mapping.sku_nick) &&
normalizeMatchTextForAdmin(input.skuNick) === normalizeMatchTextForAdmin(mapping.sku_nick)
return skuIdConflict || skuNameConflict
})
if (conflict) {
throw createHttpError('商品映射与已有启用映射冲突,请停用或调整其中一条', {
statusCode: 409,
errorCode: 'work_product_mapping_conflict',
context: {
mappingId: Number(conflict.id),
ruleKey: String(conflict.rule_key || ''),
},
})
}
})
}
function mappingProductScopesOverlap(
leftItemIds: string[],
leftTitle: string,
rightItemIds: string[],
rightTitle: string,
) {
if (leftItemIds.length > 0 && rightItemIds.length > 0) {
return leftItemIds.some((itemId) => rightItemIds.includes(itemId))
}
// 只有一侧指定商品 ID 时,另一侧可能是全商品范围,按可能重叠处理并拦截。
if (leftItemIds.length > 0 || rightItemIds.length > 0) return true
const normalizedLeftTitle = normalizeMatchTextForAdmin(leftTitle)
const normalizedRightTitle = normalizeMatchTextForAdmin(rightTitle)
if (normalizedLeftTitle || normalizedRightTitle) {
return Boolean(
normalizedLeftTitle && normalizedRightTitle && normalizedLeftTitle === normalizedRightTitle,
)
}
return true
}
function parseJsonArray(value: unknown): unknown[] {
if (Array.isArray(value)) return value
try {
@@ -592,7 +712,38 @@ function parseJsonArray(value: unknown): unknown[] {
export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
const defaults = await ensureWorkerPlatformDefaults()
const match = normalizeWorkProductRuleMatch(payload)
const ruleId = normalizeOptionalId(payload.ruleId ?? payload.rule_id)
const existingRule = ruleId ? await getWorkProductRuleById(ruleId) : null
if (ruleId && !existingRule) {
throw createHttpError('接单模板不存在', {
statusCode: 404,
errorCode: 'work_product_rule_not_found',
})
}
const hasLegacyMatchPayload = [
'match',
'sellerId',
'seller_id',
'itemId',
'item_id',
'relItemId',
'rel_item_id',
'skuId',
'sku_id',
'relSkuId',
'rel_sku_id',
'itemTitle',
'item_title',
'skuNick',
'sku_nick',
'itemTitleMatchType',
'item_title_match_type',
'skuNickMatchType',
'sku_nick_match_type',
].some((key) => Object.prototype.hasOwnProperty.call(payload, key))
const match = hasLegacyMatchPayload
? normalizeWorkProductRuleMatch(payload)
: safeParseJson(existingRule?.match_json)
const productName = String(
payload.productName || payload.product_name || payload.skuName || '',
).trim()
@@ -605,15 +756,6 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
})
}
const ruleId = normalizeOptionalId(payload.ruleId ?? payload.rule_id)
const existingRule = ruleId ? await getWorkProductRuleById(ruleId) : null
if (ruleId && !existingRule) {
throw createHttpError('接单模板不存在', {
statusCode: 404,
errorCode: 'work_product_rule_not_found',
})
}
const rewardAmount = normalizeAmountFen(payload.rewardAmount ?? payload.rewardAmountYuan, 0)
const unitPriceFen =
payload.unitPrice === undefined && payload.unitPriceYuan === undefined
@@ -678,12 +820,25 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
const ruleKey = existingRule?.rule_key || randomId('rule-')
const rule = await upsertWorkProductRule({
ruleKey,
provider: String(payload.provider || '').trim(),
platform: String(payload.platform || '').trim(),
shopId: String(payload.shopId || payload.shop_id || '').trim(),
skuCode,
provider: hasOwnPayload(payload, 'provider')
? String(payload.provider || '').trim()
: String(existingRule?.provider || '').trim(),
platform: hasOwnPayload(payload, 'platform')
? String(payload.platform || '').trim()
: String(existingRule?.platform || '').trim(),
shopId:
hasOwnPayload(payload, 'shopId') || hasOwnPayload(payload, 'shop_id')
? String(payload.shopId || payload.shop_id || '').trim()
: String(existingRule?.shop_id || '').trim(),
skuCode:
hasOwnPayload(payload, 'skuCode') || hasOwnPayload(payload, 'sku_code')
? skuCode
: String(existingRule?.sku_code || '').trim(),
productName: productName || matchedProductName,
matchType: normalizeMatchType(payload.matchType || payload.match_type),
matchType:
hasOwnPayload(payload, 'matchType') || hasOwnPayload(payload, 'match_type')
? normalizeMatchType(payload.matchType || payload.match_type)
: normalizeMatchType(existingRule?.match_type),
categoryId:
normalizeOptionalId(payload.categoryId || payload.category_id) ||
defaults.category?.id ||
@@ -738,9 +738,9 @@ function scoreWorkProductRuleMapping(
)
const hasProductScope = Boolean(mappedItemIds.length > 0 || mappedTitle)
if (mappingType === 'product_default' || hasProductScope) {
// 商品 ID 时只能按 ID 命中,避免同店同标题的不同商品串单;缺失 ID 才按标题回退
// 配置了商品 ID 时必须要求订单也提供商品 ID,避免缺少 ID 时误落入同店其他商品
if (
(mappedItemIds.length > 0 && hasContextItemId && !itemIdMatched) ||
(mappedItemIds.length > 0 && (!hasContextItemId || !itemIdMatched)) ||
(mappedItemIds.length === 0 && !titleMatched)
) {
return null
@@ -351,6 +351,31 @@ test('商品默认映射支持用逗号填写多个关联商品 ID', () => {
assert.equal(decision.rule?.id, rule.id)
})
test('商品默认映射配置商品 ID 时,订单缺少商品 ID 不应误命中', () => {
const item = buildOrderItemRow()
item.item_snapshot_json = {
kuaishouSendCode: {
...item.item_snapshot_json.kuaishouSendCode,
relItemId: '',
},
}
const rule = buildProductRule({ product_name: '' })
const decision = resolveMatchingProductRuleDecision(
buildOrderRow(),
item,
[rule],
[
buildProductMapping({
rel_item_id: '26765374805642',
item_title: '',
}),
],
)
assert.equal(decision.reason, 'unmatched')
assert.equal(decision.rule, null)
})
test('SKU 数量系列不会按公共词误匹配其他系列', () => {
const item = buildOrderItemRow()
item.sku_name = '指挥官隐藏款1个'