- migration 114 将 sales_daily_facts.fulfillment_mode 重命名为 channel - DTO/查询参数/前端类型从 fulfillmentMode 统一为 channel - 成本中心类型 FulfillmentMode 更名为 FulfillmentChannel,并补齐卡券/待分类/归档取值 - 销售事实响应分组改为 byChannel
564 lines
17 KiB
TypeScript
564 lines
17 KiB
TypeScript
import {
|
||
CheckOutlined,
|
||
CopyOutlined,
|
||
EyeOutlined,
|
||
LinkOutlined,
|
||
ReloadOutlined,
|
||
SearchOutlined,
|
||
ShopOutlined,
|
||
} from '@ant-design/icons'
|
||
import {
|
||
App,
|
||
Button,
|
||
Card,
|
||
Form,
|
||
Input,
|
||
Segmented,
|
||
Select,
|
||
Space,
|
||
Tag,
|
||
Tooltip,
|
||
Typography,
|
||
} from 'antd'
|
||
import PercentTable from '@/components/common/PercentTable'
|
||
import type { TableColumnsType } from 'antd'
|
||
import { useQuery } from '@tanstack/react-query'
|
||
import { useEffect, useMemo, useState } from 'react'
|
||
import { useNavigate, useSearchParams } from 'react-router'
|
||
|
||
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
|
||
import { ManualFulfillmentConfirmModal } from '@/components/admin/ManualFulfillmentConfirmModal'
|
||
import PageHeader from '@/components/admin/PageHeader'
|
||
import StatusTag from '@/components/admin/StatusTag'
|
||
import dayjs from '@/lib/dayjs'
|
||
import { fetchAdminOrders } from '@/services/admin'
|
||
import type { AdminOrderListItem } from '@/types/admin'
|
||
import {
|
||
resolveOrderFulfillmentMode,
|
||
resolveOrderPlatformBadge,
|
||
resolveVoucherStateLabel,
|
||
} from '@/utils/admin-display'
|
||
import {
|
||
adminOrderFulfillmentModeOptions,
|
||
adminOrderPlatformSegmentedOptions,
|
||
adminOrderProviderOptions,
|
||
adminPayStatusOptions,
|
||
adminTaskStatusOptions,
|
||
} from '@/utils/admin-options'
|
||
import {
|
||
ADMIN_DEFAULT_PAGE_SIZE,
|
||
buildAdminTablePagination,
|
||
resolveAdminPageSize,
|
||
} from '@/utils/admin-pagination'
|
||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||
|
||
type OrderFilterForm = {
|
||
keyword?: string
|
||
provider?: string
|
||
channel?: string
|
||
payStatus?: string
|
||
taskStatus?: string
|
||
dateRange?: unknown
|
||
}
|
||
|
||
export default function AdminOrdersPage() {
|
||
const navigate = useNavigate()
|
||
const [fulfillOpen, setFulfillOpen] = useState(false)
|
||
const [fulfillOrderId, setFulfillOrderId] = useState<number | null>(null)
|
||
const { message } = App.useApp()
|
||
const [form] = Form.useForm<OrderFilterForm>()
|
||
const [searchParams, setSearchParams] = useSearchParams()
|
||
const page = Number(searchParams.get('page') || 1) || 1
|
||
const pageSize = resolveAdminPageSize(searchParams.get('pageSize'), ADMIN_DEFAULT_PAGE_SIZE)
|
||
|
||
const filters = {
|
||
keyword: searchParams.get('keyword') || '',
|
||
provider: searchParams.get('provider') || '',
|
||
channel: searchParams.get('channel') || '',
|
||
payStatus: searchParams.get('payStatus') || '',
|
||
taskStatus: searchParams.get('taskStatus') || '',
|
||
dateFrom: searchParams.get('dateFrom') || '',
|
||
dateTo: searchParams.get('dateTo') || '',
|
||
}
|
||
|
||
const queryParams = useMemo(
|
||
() => ({ page, pageSize, ...filters }),
|
||
[
|
||
filters.dateFrom,
|
||
filters.dateTo,
|
||
filters.channel,
|
||
filters.keyword,
|
||
filters.payStatus,
|
||
filters.provider,
|
||
filters.taskStatus,
|
||
page,
|
||
pageSize,
|
||
],
|
||
)
|
||
|
||
const query = useQuery({
|
||
queryKey: ['admin-orders', queryParams],
|
||
queryFn: () => fetchAdminOrders(queryParams),
|
||
})
|
||
const data = query.data?.data
|
||
|
||
useEffect(() => {
|
||
form.setFieldsValue({
|
||
keyword: filters.keyword || undefined,
|
||
provider: filters.provider || undefined,
|
||
channel: filters.channel || undefined,
|
||
payStatus: filters.payStatus || undefined,
|
||
taskStatus: filters.taskStatus || undefined,
|
||
dateRange:
|
||
filters.dateFrom && filters.dateTo
|
||
? [dayjs(filters.dateFrom), dayjs(filters.dateTo)]
|
||
: undefined,
|
||
})
|
||
}, [
|
||
filters.dateFrom,
|
||
filters.dateTo,
|
||
filters.channel,
|
||
filters.keyword,
|
||
filters.payStatus,
|
||
filters.provider,
|
||
filters.taskStatus,
|
||
form,
|
||
])
|
||
|
||
const handleCopyText = (text: string, label: string) => {
|
||
if (!text) return
|
||
navigator.clipboard
|
||
.writeText(text)
|
||
.then(() => message.success(`已复制 ${label}`))
|
||
.catch(() => message.error('复制失败,请手动复制'))
|
||
}
|
||
|
||
const handleSegmentedChange = (val: string) => {
|
||
form.setFieldValue('provider', val || undefined)
|
||
setSearchParams(
|
||
cleanParams({
|
||
...filters,
|
||
provider: val,
|
||
page: 1,
|
||
pageSize,
|
||
}),
|
||
)
|
||
}
|
||
|
||
const columns: TableColumnsType<AdminOrderListItem> = [
|
||
{
|
||
title: '平台 / 订单号',
|
||
minWidth: 230,
|
||
render: (_, row) => {
|
||
const platformBadge = resolveOrderPlatformBadge(row)
|
||
const orderNo = row.platformOrderId || String(row.orderId)
|
||
|
||
return (
|
||
<div className="cell-stack" style={{ gap: 5 }}>
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||
<Tag
|
||
style={{
|
||
margin: 0,
|
||
fontWeight: 600,
|
||
fontSize: 12,
|
||
borderColor: platformBadge.color,
|
||
color: platformBadge.color,
|
||
}}
|
||
>
|
||
{platformBadge.label}
|
||
</Tag>
|
||
{row.shopName ? (
|
||
<span
|
||
className="muted"
|
||
style={{
|
||
fontSize: 12,
|
||
maxWidth: 130,
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
whiteSpace: 'nowrap',
|
||
}}
|
||
title={row.shopName}
|
||
>
|
||
<ShopOutlined style={{ marginRight: 3 }} />
|
||
{row.shopName}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
|
||
<Space size={4} align="center">
|
||
<Typography.Link
|
||
strong
|
||
style={{ fontSize: 13 }}
|
||
onClick={() => navigate(`/admin/orders/${row.orderId}`)}
|
||
title={`查看订单 #${row.orderId}`}
|
||
>
|
||
{orderNo}
|
||
</Typography.Link>
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
icon={<CopyOutlined />}
|
||
title="复制订单号"
|
||
style={{ padding: 0, height: 'auto', color: '#94a3b8' }}
|
||
onClick={(e) => {
|
||
e.stopPropagation()
|
||
handleCopyText(orderNo, '订单号')
|
||
}}
|
||
/>
|
||
</Space>
|
||
</div>
|
||
)
|
||
},
|
||
},
|
||
{
|
||
title: '平台 SKU',
|
||
minWidth: 190,
|
||
render: (_, row) => {
|
||
const items = row.items && row.items.length > 0 ? row.items : null
|
||
const fallbackSku = row.matchSku || row.skuNick || '未识别 SKU'
|
||
|
||
const renderSkuTag = (sku: string | undefined, title?: string, key?: string) => {
|
||
const label = sku || '未识别 SKU'
|
||
return (
|
||
<Tooltip
|
||
key={key}
|
||
title={
|
||
title && title !== label
|
||
? `平台 SKU:${label}|原商品标题:${title}`
|
||
: `平台 SKU:${label}`
|
||
}
|
||
>
|
||
<Tag
|
||
color={label === '未识别 SKU' ? 'default' : 'blue'}
|
||
style={{
|
||
margin: 0,
|
||
maxWidth: 200,
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
whiteSpace: 'nowrap',
|
||
fontSize: 12,
|
||
}}
|
||
>
|
||
{label}
|
||
</Tag>
|
||
</Tooltip>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="cell-stack" style={{ gap: 4 }}>
|
||
{items && items.length > 1 ? (
|
||
<div className="cell-stack" style={{ gap: 4 }}>
|
||
{items
|
||
.slice(0, 2)
|
||
.map((item, idx) =>
|
||
renderSkuTag(item.matchSku || item.skuNick, item.itemTitle, `sku-${idx}`),
|
||
)}
|
||
{items.length > 2 ? <span className="muted">等 {items.length} 项</span> : null}
|
||
</div>
|
||
) : items && items.length === 1 ? (
|
||
renderSkuTag(items[0].matchSku || items[0].skuNick, items[0].itemTitle, 'sku-0')
|
||
) : (
|
||
renderSkuTag(fallbackSku, row.itemTitle || row.itemSummary, 'sku-0')
|
||
)}
|
||
<span className="muted" style={{ fontSize: 12 }}>
|
||
数量 {row.totalQuantity || 1}
|
||
{row.canClaimH5
|
||
? ` · 关联任务 ${row.taskCount || 0}`
|
||
: row.workOrderCount
|
||
? ` · 关联工单 ${row.workOrderCount}`
|
||
: ''}
|
||
</span>
|
||
</div>
|
||
)
|
||
},
|
||
},
|
||
{
|
||
title: '交付 / 状态',
|
||
minWidth: 230,
|
||
render: (_, row) => {
|
||
const channel = resolveOrderFulfillmentMode(row)
|
||
const voucherLabel = resolveVoucherStateLabel(row.voucherState)
|
||
|
||
return (
|
||
<div className="cell-stack" style={{ gap: 4 }}>
|
||
<Space size={[0, 4]} wrap>
|
||
<Tag color={channel.color} style={{ margin: 0, fontSize: 11 }}>
|
||
{channel.label}
|
||
</Tag>
|
||
{row.hasVoucher && voucherLabel ? (
|
||
<Tag color="gold" style={{ margin: 0, fontSize: 11 }}>
|
||
🎫 {voucherLabel}
|
||
</Tag>
|
||
) : null}
|
||
<StatusTag value={row.payStatus} kind="pay" />
|
||
<StatusTag value={row.resourceStatus} kind="resource" />
|
||
<StatusTag value={row.customerStatus} kind="customer" />
|
||
</Space>
|
||
|
||
{row.canClaimH5 && row.claimStatus === 'paused' ? (
|
||
<Tag color="warning" style={{ margin: 0, width: 'fit-content' }}>
|
||
H5已暂停
|
||
</Tag>
|
||
) : row.canClaimH5 && row.claimStatus === 'closed' ? (
|
||
<Tag color="default" style={{ margin: 0, width: 'fit-content' }}>
|
||
H5已关闭
|
||
</Tag>
|
||
) : null}
|
||
</div>
|
||
)
|
||
},
|
||
},
|
||
{
|
||
title: '金额 / 支付时间',
|
||
minWidth: 160,
|
||
render: (_, row) => (
|
||
<div className="cell-stack" style={{ gap: 3 }}>
|
||
<strong style={{ fontSize: 14, color: '#0f172a' }}>
|
||
¥ {row.totalAmount || '0.00'}{' '}
|
||
<span style={{ fontSize: 11, color: '#64748b' }}>{row.currency}</span>
|
||
</strong>
|
||
<span className="muted" style={{ fontSize: 12 }}>
|
||
{formatAdminDateTime(row.paidAt || row.createdAt)}
|
||
</span>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
title: '操作',
|
||
minWidth: 220,
|
||
fixed: 'right',
|
||
render: (_, row) => {
|
||
const orderNo = row.platformOrderId || String(row.orderId)
|
||
const h5Url = `${window.location.origin}/h5/${encodeURIComponent(orderNo)}`
|
||
const manualPending = Number(row.manualPendingCount || 0)
|
||
const manualUnconfigured = Number(row.manualUnconfiguredCount || 0)
|
||
|
||
return (
|
||
<Space size={6} wrap>
|
||
<Button
|
||
type="primary"
|
||
size="small"
|
||
ghost
|
||
icon={<EyeOutlined />}
|
||
onClick={() => navigate(`/admin/orders/${row.orderId}`)}
|
||
>
|
||
详情
|
||
</Button>
|
||
{manualPending > 0 ? (
|
||
<Tooltip title="人工履约:确认后扣积分/备用金并写入成本">
|
||
<Button
|
||
size="small"
|
||
icon={<CheckOutlined />}
|
||
onClick={() => {
|
||
setFulfillOrderId(row.orderId)
|
||
setFulfillOpen(true)
|
||
}}
|
||
>
|
||
确认履约
|
||
</Button>
|
||
</Tooltip>
|
||
) : manualUnconfigured > 0 ? (
|
||
<Tooltip
|
||
title={`有 ${manualUnconfigured} 个人工商品未配置履约渠道,请先到「商品与成本 → 人工 SKU 配置」补全`}
|
||
>
|
||
<Tag color="orange" style={{ margin: 0 }}>
|
||
待补全履约
|
||
</Tag>
|
||
</Tooltip>
|
||
) : null}
|
||
{row.canClaimH5 ? (
|
||
<Tooltip title="复制自动直充 H5 领取链接">
|
||
<Button
|
||
size="small"
|
||
icon={<LinkOutlined />}
|
||
onClick={() => handleCopyText(h5Url, 'H5 直充链接')}
|
||
>
|
||
H5
|
||
</Button>
|
||
</Tooltip>
|
||
) : null}
|
||
</Space>
|
||
)
|
||
},
|
||
},
|
||
]
|
||
|
||
function applyFilters(values: OrderFilterForm) {
|
||
const { dateRange, ...restValues } = values
|
||
const keyword = String(restValues.keyword || '').trim()
|
||
const [rawFrom, rawTo] = Array.isArray(dateRange)
|
||
? dateRange.map((item: { format?: (pattern: string) => string }) =>
|
||
item?.format ? item.format('YYYY-MM-DD') : '',
|
||
)
|
||
: ['', '']
|
||
|
||
// 有关键词时不强制日期(后端也会忽略日期);清空日期且无关键词则查全部时间。
|
||
const dateFrom = rawFrom || ''
|
||
const dateTo = rawTo || ''
|
||
|
||
setSearchParams(
|
||
cleanParams({
|
||
...restValues,
|
||
keyword,
|
||
dateFrom,
|
||
dateTo,
|
||
page: 1,
|
||
pageSize,
|
||
}),
|
||
)
|
||
}
|
||
|
||
function resetFilters() {
|
||
form.setFieldsValue({
|
||
keyword: undefined,
|
||
provider: undefined,
|
||
channel: undefined,
|
||
payStatus: undefined,
|
||
taskStatus: undefined,
|
||
dateRange: undefined,
|
||
})
|
||
setSearchParams(cleanParams({ page: 1, pageSize }))
|
||
}
|
||
|
||
const currentSegmentedProvider = filters.provider || ''
|
||
|
||
return (
|
||
<section className="page-stack">
|
||
<PageHeader
|
||
title="订单管理"
|
||
description="多平台订单聚合中台(快手/91卡券、抖音码速达、拼多多等),支持 H5 兑换与自动化履约管理。"
|
||
/>
|
||
|
||
<Card style={{ marginBottom: 16 }}>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
flexWrap: 'wrap',
|
||
gap: 12,
|
||
}}
|
||
>
|
||
<Segmented
|
||
size="middle"
|
||
options={adminOrderPlatformSegmentedOptions}
|
||
value={currentSegmentedProvider}
|
||
onChange={(val) => handleSegmentedChange(String(val))}
|
||
/>
|
||
</div>
|
||
|
||
<Form<OrderFilterForm>
|
||
form={form}
|
||
layout="inline"
|
||
onFinish={applyFilters}
|
||
className="filter-form"
|
||
style={{ rowGap: 12 }}
|
||
>
|
||
<Form.Item name="keyword">
|
||
<Input
|
||
allowClear
|
||
placeholder="订单号 / 商品标题 / SKU / 买家 / 店铺"
|
||
style={{ width: 280 }}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="provider">
|
||
<Select
|
||
allowClear
|
||
placeholder="平台/渠道"
|
||
options={adminOrderProviderOptions}
|
||
style={{ minWidth: 140 }}
|
||
onChange={(val) => {
|
||
form.setFieldValue('provider', val)
|
||
}}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="channel">
|
||
<Select
|
||
allowClear
|
||
placeholder="履约模式"
|
||
options={adminOrderFulfillmentModeOptions}
|
||
style={{ minWidth: 130 }}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="payStatus">
|
||
<Select
|
||
allowClear
|
||
placeholder="支付状态"
|
||
options={adminPayStatusOptions.filter((item) => item.value)}
|
||
style={{ minWidth: 120 }}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="taskStatus">
|
||
<Select
|
||
allowClear
|
||
showSearch
|
||
optionFilterProp="label"
|
||
placeholder="任务状态"
|
||
options={adminTaskStatusOptions.filter((item) => item.value)}
|
||
style={{ minWidth: 140 }}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item name="dateRange">
|
||
<AdminRangePicker allowClear placeholder={['支付开始', '支付结束']} />
|
||
</Form.Item>
|
||
<Form.Item>
|
||
<Space>
|
||
<Button type="primary" icon={<SearchOutlined />} htmlType="submit">
|
||
查询
|
||
</Button>
|
||
<Button icon={<ReloadOutlined />} onClick={resetFilters}>
|
||
重置
|
||
</Button>
|
||
</Space>
|
||
</Form.Item>
|
||
</Form>
|
||
</div>
|
||
</Card>
|
||
|
||
<Card>
|
||
<PercentTable<AdminOrderListItem>
|
||
rowKey="orderId"
|
||
loading={query.isLoading || query.isFetching}
|
||
columns={columns}
|
||
dataSource={data?.items || []}
|
||
scroll={{ x: 960 }}
|
||
pagination={buildAdminTablePagination({
|
||
current: data?.pagination.page || page,
|
||
pageSize: data?.pagination.pageSize || pageSize,
|
||
total: data?.pagination.total || 0,
|
||
onChange: (nextPage, nextPageSize) => {
|
||
setSearchParams(
|
||
cleanParams({
|
||
...filters,
|
||
page: nextPage,
|
||
pageSize: nextPageSize,
|
||
}),
|
||
)
|
||
},
|
||
})}
|
||
/>
|
||
</Card>
|
||
|
||
<ManualFulfillmentConfirmModal
|
||
open={fulfillOpen}
|
||
orderId={fulfillOrderId}
|
||
onClose={() => setFulfillOpen(false)}
|
||
onConfirmed={() => void query.refetch()}
|
||
/>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function cleanParams(params: Record<string, unknown>) {
|
||
const next = new URLSearchParams()
|
||
Object.entries(params).forEach(([key, value]) => {
|
||
const normalized = String(value ?? '').trim()
|
||
if (normalized) {
|
||
next.set(key, normalized)
|
||
}
|
||
})
|
||
return next
|
||
}
|