Files
order_site/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx
T
yml2213 6d86d4df7e refactor(admin-web): 财务各页去掉大标题,统一由财务导航栏进入
- 销售毛利 / 收支明细 / 商品与成本中心 / 履约渠道 移除 PageHeader 大标题块
- 与钱包管理、总账与对账保持一致(这两页本来就没有大标题)
- 成本中心的「刷新」按钮并入统计口径行,功能位置不变
2026-10-31 18:42:20 +08:00

412 lines
12 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 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">
<FinanceSubNav current="/admin/fulfillment-channel" />
<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}`
}