feat(admin-web): 新增履约渠道页,含待分类队列与渠道规则

- 待分类按 SKU 聚合,可直接指定渠道(含第三方卡券仅统计渠道)
- 渠道规则支持新增/删除,展示优先级与作用范围
- 问题队列按类型筛选,统一使用 PercentTable
This commit is contained in:
yml2213
2026-10-31 18:12:31 +08:00
parent 855f2efcff
commit 9b01443424
5 changed files with 544 additions and 0 deletions
+2
View File
@@ -24,6 +24,7 @@ const AdminFinancePage = lazy(() => import('@/pages/admin/AdminFinancePage'))
const AdminFinanceLedgerPage = lazy(() => import('@/pages/admin/AdminFinanceLedgerPage'))
const AdminSalesProfitPage = lazy(() => import('@/pages/admin/AdminSalesProfitPage'))
const AdminCostCenterPage = lazy(() => import('@/pages/admin/AdminCostCenterPage'))
const AdminFulfillmentChannelPage = lazy(() => import('@/pages/admin/AdminFulfillmentChannelPage'))
const AdminFundPage = lazy(() => import('@/pages/admin/AdminFundPage'))
const AdminAuditLogsPage = lazy(() => import('@/pages/admin/AdminAuditLogsPage'))
const AdminKuaishouIndustryPage = lazy(() => import('@/pages/admin/AdminKuaishouIndustryPage'))
@@ -153,6 +154,7 @@ function AdminApplication() {
<Route path="finance/ledger" element={<AdminFinanceLedgerPage />} />
<Route path="sales-profit" element={<AdminSalesProfitPage />} />
<Route path="cost-center" element={<AdminCostCenterPage />} />
<Route path="fulfillment-channel" element={<AdminFulfillmentChannelPage />} />
</Route>
<Route element={<RequireRole roles={['admin', 'operator', 'support', 'finance']} />}>
<Route path="fund" element={<AdminFundPage />} />
@@ -1,6 +1,7 @@
import {
AccountBookOutlined,
ApartmentOutlined,
BranchesOutlined,
AuditOutlined,
LineChartOutlined,
WalletOutlined,
@@ -21,6 +22,11 @@ export const FINANCE_NAV_ITEMS = [
label: '商品与成本中心',
icon: <ApartmentOutlined />,
},
{
key: '/admin/fulfillment-channel',
label: '履约渠道',
icon: <BranchesOutlined />,
},
{
key: '/admin/fund',
label: '钱包管理',
@@ -0,0 +1,416 @@
/**
* 履约渠道页:待分类队列 + 渠道规则 + 问题队列。
*
* 设计口径(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}`
}
@@ -0,0 +1,119 @@
import { apiDelete, apiGet, apiPost } from '@/lib/http'
/** 待分类队列(按 SKU 聚合)。 */
export type PendingChannelGroup = {
provider: string
shop_id: string
sku_code: string
sku_name: string
item_count: number
revenue_fen: number
first_seen_at: string
last_seen_at: string
}
export type PendingChannelGroupListResult = {
items: PendingChannelGroup[]
total: number
page: number
pageSize: number
}
/** 渠道规则(L1)。 */
export type ChannelRule = {
id: number
provider: string
shop_id: string
match_key_type: 'sku_code' | 'internal_sku' | 'sku_name'
match_key: string
channel: 'work_order' | 'affiliate_dash' | 'third_party_voucher' | 'manual'
priority: number
enabled: boolean
}
export type ChannelException = {
id: number
reason: string
status: string
scope_key: string
item_count: number
first_seen_at: string
last_seen_at: string
detail_json: unknown
}
export type ChannelExceptionListResult = {
items: ChannelException[]
total: number
page: number
pageSize: number
}
export type PipelineRunResult = Record<string, unknown>
export function fetchPendingChannelGroups(
params: {
page?: number
pageSize?: number
keyword?: string
} = {},
) {
return apiGet<PendingChannelGroupListResult>('/api/v1/admin/fulfillment/pending', params)
}
export function fetchChannelExceptions(
params: {
reason?: string
status?: string
page?: number
pageSize?: number
} = {},
) {
return apiGet<ChannelExceptionListResult>('/api/v1/admin/fulfillment/exceptions', params)
}
export function assignChannelBySku(payload: {
provider: string
shopId: string
skuCode: string
channel: string
note?: string
}) {
return apiPost<{ updated: number }>('/api/v1/admin/fulfillment/assign', payload)
}
export function assignChannelToItems(payload: {
orderItemIds: number[]
channel: string
note?: string
}) {
return apiPost<{ updated: number }>('/api/v1/admin/fulfillment/assign', payload)
}
export function fetchChannelRules(params: { channel?: string } = {}) {
return apiGet<ChannelRule[]>('/api/v1/admin/fulfillment/rules', params)
}
export function saveChannelRule(payload: {
id?: number
provider?: string
shopId?: string
matchKeyType: string
matchKey: string
channel: string
priority?: number
enabled?: boolean
note?: string
}) {
return apiPost<{ id: number }>('/api/v1/admin/fulfillment/rules', payload)
}
export function deleteChannelRule(ruleId: number) {
return apiDelete<{ deleted: boolean }>(`/api/v1/admin/fulfillment/rules/${ruleId}`)
}
export function runFinancePipeline(payload: { days?: number } = {}) {
return apiPost<PipelineRunResult>('/api/v1/admin/fulfillment/pipeline/run', payload, {
timeout: 300_000,
})
}
@@ -3,6 +3,7 @@ export * from './dashboard'
export * from './finance'
export * from './finance-ledger'
export * from './cost-center'
export * from './fulfillment-channel'
export * from './fund'
export * from './users'
export * from './audit-logs'