Files
order_site/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx
T
yml2213 9b01443424 feat(admin-web): 新增履约渠道页,含待分类队列与渠道规则
- 待分类按 SKU 聚合,可直接指定渠道(含第三方卡券仅统计渠道)
- 渠道规则支持新增/删除,展示优先级与作用范围
- 问题队列按类型筛选,统一使用 PercentTable
2026-10-31 18:12:31 +08:00

417 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 履约渠道页:待分类队列 + 渠道规则 + 问题队列。
*
* 设计口径(2026-10-31 v2):
* · 渠道由 L1 规则决定,不再由「有没有任务」反推;
* · 没有命中规则的新单落在「待分类」,收入进未分类桶,不产生成本噪音;
* · 第三方卡券是「只统计」渠道:不建任务、不发领取页。
*/
import { PlusOutlined, ReloadOutlined, ThunderboltOutlined } from '@ant-design/icons'
import {
Alert,
Button,
Card,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Switch,
Tabs,
Tag,
Typography,
message,
} from 'antd'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import PageHeader from '@/components/admin/PageHeader'
import PercentTable from '@/components/common/PercentTable'
import FinanceSubNav from '@/components/admin/FinanceSubNav'
import {
assignChannelBySku,
deleteChannelRule,
fetchChannelExceptions,
fetchChannelRules,
fetchPendingChannelGroups,
runFinancePipeline,
saveChannelRule,
type ChannelRule,
type PendingChannelGroup,
} from '@/services/admin'
import { formatRedeemAmount } from '@/utils/redeem'
import { formatDateTime } from '@/utils/date-time'
const CHANNEL_OPTIONS = [
{ value: 'work_order', label: '接单平台' },
{ value: 'affiliate_dash', label: '自动发货' },
{ value: 'third_party_voucher', label: '第三方卡券(仅统计)' },
{ value: 'manual', label: '人工客服' },
]
const CHANNEL_COLOR: Record<string, string> = {
work_order: 'blue',
affiliate_dash: 'green',
third_party_voucher: 'purple',
manual: 'orange',
}
const MATCH_KEY_OPTIONS = [
{ value: 'sku_code', label: '平台 SKU' },
{ value: 'internal_sku', label: '内部 SKU' },
{ value: 'sku_name', label: 'SKU 名称' },
]
export default function AdminFulfillmentChannelPage() {
return (
<div className="page-container">
<PageHeader
title="履约渠道"
description="新单先按规则判定渠道;没有规则的单进待分类队列,收入单独计入未分类桶。"
/>
<FinanceSubNav />
<Tabs
defaultActiveKey="pending"
items={[
{ key: 'pending', label: '待分类队列', children: <PendingPanel /> },
{ key: 'rules', label: '渠道规则', children: <RulesPanel /> },
{ key: 'exceptions', label: '问题队列', children: <ExceptionsPanel /> },
]}
/>
</div>
)
}
function PendingPanel() {
const queryClient = useQueryClient()
const [keyword, setKeyword] = useState('')
const [selections, setSelections] = useState<Record<string, string>>({})
const query = useQuery({
queryKey: ['fulfillment-pending', keyword],
queryFn: () => fetchPendingChannelGroups({ page: 1, pageSize: 100, keyword }),
})
const assignMutation = useMutation({
mutationFn: (row: PendingChannelGroup) =>
assignChannelBySku({
provider: row.provider,
shopId: row.shop_id,
skuCode: row.sku_code,
channel: selections[rowKey(row)] || '',
}),
onSuccess: (result) => {
message.success(`已指定 ${result.data.updated} 个订单项`)
void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] })
},
onError: () => message.error('指定渠道失败'),
})
const pipelineMutation = useMutation({
mutationFn: () => runFinancePipeline({ days: 3 }),
onSuccess: () => {
message.success('管道已执行')
void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] })
},
onError: () => message.error('执行管道失败'),
})
const columns = [
{ title: '来源', dataIndex: 'provider', width: 120 },
{ title: '店铺', dataIndex: 'shop_id', width: 140 },
{ title: 'SKU', dataIndex: 'sku_code', width: 220 },
{ title: '商品名', dataIndex: 'sku_name', ellipsis: true },
{ title: '订单数', dataIndex: 'item_count', width: 90 },
{
title: '收入',
dataIndex: 'revenue_fen',
width: 120,
render: (value: number) => formatRedeemAmount(value),
},
{
title: '最近出现',
dataIndex: 'last_seen_at',
width: 160,
render: (value: string) => formatDateTime(value),
},
{
title: '指定渠道',
width: 320,
render: (_: unknown, row: PendingChannelGroup) => (
<Space>
<Select
size="small"
style={{ width: 180 }}
placeholder="选择渠道"
options={CHANNEL_OPTIONS}
value={selections[rowKey(row)]}
onChange={(value) => setSelections((prev) => ({ ...prev, [rowKey(row)]: value }))}
/>
<Button
size="small"
type="primary"
disabled={!selections[rowKey(row)]}
loading={assignMutation.isPending}
onClick={() => assignMutation.mutate(row)}
>
指定
</Button>
</Space>
),
},
]
return (
<Card
title={`待分类(${query.data?.data?.total ?? 0} 个 SKU)`}
extra={
<Space>
<Input.Search
allowClear
placeholder="SKU / 商品名"
style={{ width: 220 }}
onSearch={(value) => setKeyword(value)}
/>
<Button icon={<ReloadOutlined />} onClick={() => void query.refetch()}>
刷新
</Button>
<Button
icon={<ThunderboltOutlined />}
loading={pipelineMutation.isPending}
onClick={() => pipelineMutation.mutate()}
>
立即跑一遍管道
</Button>
</Space>
}
>
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message="待分类订单的收入已经计入「未分类」桶,不会丢失;指定渠道后会自动进入商品归一与成本核算。"
/>
<PercentTable
rowKey={(row) => rowKey(row)}
size="small"
loading={query.isFetching}
dataSource={query.data?.data?.items || []}
columns={columns}
pagination={{ pageSize: 20, showSizeChanger: false }}
/>
</Card>
)
}
function RulesPanel() {
const queryClient = useQueryClient()
const [open, setOpen] = useState(false)
const [form] = Form.useForm()
const query = useQuery({
queryKey: ['fulfillment-rules'],
queryFn: () => fetchChannelRules(),
})
const saveMutation = useMutation({
mutationFn: (values: Record<string, unknown>) => saveChannelRule(values as never),
onSuccess: () => {
message.success('规则已保存')
setOpen(false)
form.resetFields()
void queryClient.invalidateQueries({ queryKey: ['fulfillment-rules'] })
},
onError: () => message.error('保存规则失败'),
})
const deleteMutation = useMutation({
mutationFn: (id: number) => deleteChannelRule(id),
onSuccess: () => {
message.success('规则已删除')
void queryClient.invalidateQueries({ queryKey: ['fulfillment-rules'] })
},
onError: () => message.error('删除规则失败'),
})
const columns = [
{ title: '来源', dataIndex: 'provider', width: 120, render: (v: string) => v || '不限' },
{ title: '店铺', dataIndex: 'shop_id', width: 140, render: (v: string) => v || '不限' },
{
title: '匹配键',
dataIndex: 'match_key_type',
width: 110,
render: (v: string) => MATCH_KEY_OPTIONS.find((item) => item.value === v)?.label || v,
},
{ title: '匹配值', dataIndex: 'match_key' },
{
title: '渠道',
dataIndex: 'channel',
width: 140,
render: (value: string) => (
<Tag color={CHANNEL_COLOR[value] || 'default'}>
{CHANNEL_OPTIONS.find((item) => item.value === value)?.label || value}
</Tag>
),
},
{ title: '优先级', dataIndex: 'priority', width: 90 },
{
title: '启用',
dataIndex: 'enabled',
width: 80,
render: (value: boolean) => (value ? <Tag color="green">启用</Tag> : <Tag>停用</Tag>),
},
{
title: '操作',
width: 100,
render: (_: unknown, row: ChannelRule) => (
<Button
danger
size="small"
loading={deleteMutation.isPending}
onClick={() => deleteMutation.mutate(row.id)}
>
删除
</Button>
),
},
]
return (
<Card
title={`渠道规则(${query.data?.data?.length ?? 0})`}
extra={
<Space>
<Button icon={<ReloadOutlined />} onClick={() => void query.refetch()}>
刷新
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setOpen(true)}>
新增规则
</Button>
</Space>
}
>
<Typography.Paragraph type="secondary">
接单平台由接单模板匹配决定,不在此表维护;这里负责自动发货、人工、第三方卡券三个渠道与运营强制覆盖。
同优先级命中多个渠道时订单保持待分类并进「配置冲突」。
</Typography.Paragraph>
<PercentTable
rowKey="id"
size="small"
loading={query.isFetching}
dataSource={query.data?.data || []}
columns={columns}
pagination={{ pageSize: 20, showSizeChanger: false }}
/>
<Modal
title="新增渠道规则"
open={open}
onCancel={() => setOpen(false)}
onOk={() => form.submit()}
confirmLoading={saveMutation.isPending}
>
<Form
form={form}
layout="vertical"
initialValues={{ matchKeyType: 'sku_code', channel: 'third_party_voucher', priority: 10 }}
onFinish={(values) => saveMutation.mutate(values)}
>
<Form.Item name="provider" label="来源(留空=不限)">
<Input placeholder="91kaquan / pdd / douyin_mashida" />
</Form.Item>
<Form.Item name="shopId" label="店铺(留空=不限)">
<Input />
</Form.Item>
<Form.Item name="matchKeyType" label="匹配键" rules={[{ required: true }]}>
<Select options={MATCH_KEY_OPTIONS} />
</Form.Item>
<Form.Item name="matchKey" label="匹配值" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="channel" label="渠道" rules={[{ required: true }]}>
<Select options={CHANNEL_OPTIONS} />
</Form.Item>
<Form.Item name="priority" label="优先级(越小越优先)">
<InputNumber min={1} max={999} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="note" label="备注">
<Input />
</Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked" initialValue>
<Switch />
</Form.Item>
</Form>
</Modal>
</Card>
)
}
function ExceptionsPanel() {
const [reason, setReason] = useState('')
const query = useQuery({
queryKey: ['fulfillment-exceptions', reason],
queryFn: () => fetchChannelExceptions({ reason, status: 'open', page: 1, pageSize: 100 }),
})
const REASON_OPTIONS = [
{ value: '', label: '全部' },
{ value: 'classification_missing', label: '待分类' },
{ value: 'routing_conflict', label: '规则冲突' },
{ value: 'product_mapping_missing', label: '未归一' },
{ value: 'cost_missing', label: '成本缺失' },
{ value: 'manual_config_missing', label: '人工 SKU 未配置' },
]
const columns = [
{
title: '类型',
dataIndex: 'reason',
width: 200,
render: (value: string) => (
<Tag>{REASON_OPTIONS.find((item) => item.value === value)?.label || value}</Tag>
),
},
{ title: '范围', dataIndex: 'scope_key', ellipsis: true },
{ title: '涉及订单项', dataIndex: 'item_count', width: 110 },
{
title: '最近出现',
dataIndex: 'last_seen_at',
width: 160,
render: (value: string) => formatDateTime(value),
},
]
return (
<Card
title="问题队列(按 SKU 聚合)"
extra={
<Space>
<Select
style={{ width: 180 }}
value={reason}
options={REASON_OPTIONS}
onChange={setReason}
/>
<Button icon={<ReloadOutlined />} onClick={() => void query.refetch()}>
刷新
</Button>
</Space>
}
>
<PercentTable
rowKey="id"
size="small"
loading={query.isFetching}
dataSource={query.data?.data?.items || []}
columns={columns}
pagination={{ pageSize: 20, showSizeChanger: false }}
/>
</Card>
)
}
function rowKey(row: PendingChannelGroup) {
return `${row.provider}|${row.shop_id}|${row.sku_code}`
}