diff --git a/apps/backend/src/routes/admin/fulfillment-channel.ts b/apps/backend/src/routes/admin/fulfillment-channel.ts index 6d6f6e47..367fc7b7 100644 --- a/apps/backend/src/routes/admin/fulfillment-channel.ts +++ b/apps/backend/src/routes/admin/fulfillment-channel.ts @@ -249,6 +249,8 @@ router.get( (req) => listChannelRulesPage({ channel: String(req.query.channel || ''), + source: String(req.query.source || ''), + keyword: String(req.query.keyword || ''), page: Number(req.query.page), pageSize: Number(req.query.pageSize), ...(req.query.enabled === undefined diff --git a/apps/backend/src/services/fulfillment/channel-assign-service.ts b/apps/backend/src/services/fulfillment/channel-assign-service.ts index c80313f1..d0c399a7 100644 --- a/apps/backend/src/services/fulfillment/channel-assign-service.ts +++ b/apps/backend/src/services/fulfillment/channel-assign-service.ts @@ -1098,6 +1098,8 @@ export async function assignChannelBySku(input: { type ChannelRuleFilterInput = { channel?: string + source?: string + keyword?: string enabled?: boolean } @@ -1108,6 +1110,20 @@ function buildChannelRuleFilter(input: ChannelRuleFilterInput) { params.push(input.channel) clauses.push(`channel = $${params.length}`) } + const source = String(input.source || '').trim() + if (source === 'rule') { + clauses.push(`source LIKE 'rule:%'`) + } else if (source) { + params.push(source) + clauses.push(`source = $${params.length}`) + } + const keyword = String(input.keyword || '').trim() + if (keyword) { + params.push(`%${keyword}%`) + clauses.push( + `(match_key ILIKE $${params.length} OR config_json::text ILIKE $${params.length} OR COALESCE(note, '') ILIKE $${params.length})`, + ) + } if (typeof input.enabled === 'boolean') { params.push(input.enabled) clauses.push(`enabled = $${params.length}`) diff --git a/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts b/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts index ce19868d..97f35d17 100644 --- a/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts +++ b/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts @@ -401,6 +401,15 @@ test( assert.equal(firstRulePage.pageSize, 2) assert.ok(firstRulePage.items.length <= 2) assert.equal(firstRulePage.total, listedRules.length) + const searchedRules = await listChannelRulesPage({ + keyword: voucherSku, + source: 'operator', + enabled: true, + page: 1, + pageSize: 10, + }) + assert.ok(searchedRules.items.some((rule) => rule.id === savedRule.id)) + assert.ok(searchedRules.items.every((rule) => rule.source === 'operator')) const deletedRules = await deleteChannelRules([savedRule.id]) assert.equal(deletedRules.deletedCount, 1) assert.deepEqual(deletedRules.deletedIds, [savedRule.id]) diff --git a/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx b/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx index 9080af82..5a77b171 100644 --- a/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx +++ b/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx @@ -10,6 +10,7 @@ import { DeleteOutlined, PlusOutlined, ReloadOutlined, + SearchOutlined, ThunderboltOutlined, } from '@ant-design/icons' import { @@ -25,6 +26,7 @@ import { Switch, Tabs, Tag, + Tooltip, Typography, message, } from 'antd' @@ -77,6 +79,16 @@ const CHANNEL_SOURCE_LABEL: Record = { backfill: '历史回填', } +const CHANNEL_SOURCE_FILTER_OPTIONS = [ + ...Object.entries(CHANNEL_SOURCE_LABEL).map(([value, label]) => ({ value, label })), + { value: 'rule', label: '规则迁移' }, +] + +const ENABLED_FILTER_OPTIONS = [ + { value: 'true', label: '启用' }, + { value: 'false', label: '停用' }, +] + const MATCH_KEY_OPTIONS = [{ value: 'internal_sku', label: '内部 SKU' }] const INTERNAL_SKU_MAPPING_TYPES: Array<{ @@ -509,13 +521,34 @@ function RulesPanel() { const queryClient = useQueryClient() const [open, setOpen] = useState(false) const [selectedRuleIds, setSelectedRuleIds] = useState([]) + const [keyword, setKeyword] = useState('') + const [channelFilter, setChannelFilter] = useState('') + const [sourceFilter, setSourceFilter] = useState('') + const [enabledFilter, setEnabledFilter] = useState('') const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) const [form] = Form.useForm() + const hasActiveFilters = Boolean(keyword || channelFilter || sourceFilter || enabledFilter) const query = useQuery({ - queryKey: ['fulfillment-rules', page, pageSize], - queryFn: () => fetchChannelRules({ page, pageSize }), + queryKey: [ + 'fulfillment-rules', + keyword, + channelFilter, + sourceFilter, + enabledFilter, + page, + pageSize, + ], + queryFn: () => + fetchChannelRules({ + keyword, + channel: channelFilter, + source: sourceFilter, + ...(enabledFilter ? { enabled: enabledFilter === 'true' } : {}), + page, + pageSize, + }), }) const ruleCount = query.data?.data?.total || 0 @@ -549,6 +582,15 @@ function RulesPanel() { onError: (error) => message.error(error instanceof Error ? error.message : '删除规则失败'), }) + function resetFilters() { + setKeyword('') + setChannelFilter('') + setSourceFilter('') + setEnabledFilter('') + setPage(1) + setSelectedRuleIds([]) + } + function confirmDeleteRules(ids: number[]) { if (ids.length === 0) return Modal.confirm({ @@ -603,17 +645,26 @@ function RulesPanel() { { title: '操作', width: 100, - render: (_: unknown, row: ChannelRule) => ( - - ), + render: (_: unknown, row: ChannelRule) => { + const button = ( + + ) + return canDeleteChannelRule(row) ? ( + button + ) : ( + + {button} + + ) + }, }, ] @@ -643,7 +694,62 @@ function RulesPanel() { 配置按内部 SKU 全局生效,会同时影响所有平台和店铺。每条内部 SKU 只能有一个主渠道; 其他渠道请设置 90 以上优先级作为兜底。接单平台的具体模板由统一配置中的 workRuleKey 控制。 + 来源为商品能力迁移、人工配置、卡券或历史回填的系统规则受保护,配置有误时请新增同 SKU + 的正确规则覆盖。 + + } + placeholder="搜索内部 SKU、载体参数或备注" + value={keyword} + onChange={(event) => { + setKeyword(event.target.value) + setPage(1) + setSelectedRuleIds([]) + }} + style={{ width: 280 }} + /> + { + setSourceFilter(value || '') + setPage(1) + setSelectedRuleIds([]) + }} + options={CHANNEL_SOURCE_FILTER_OPTIONS} + style={{ width: 160 }} + /> +