Files
order_site/apps/frontend/src/pages/admin/AdminOrdersPage.tsx
T
yml2213 6cd6401eca refactor(finance)!: 事实维度与接口字段统一改名 channel
- migration 114 将 sales_daily_facts.fulfillment_mode 重命名为 channel
- DTO/查询参数/前端类型从 fulfillmentMode 统一为 channel
- 成本中心类型 FulfillmentMode 更名为 FulfillmentChannel,并补齐卡券/待分类/归档取值
- 销售事实响应分组改为 byChannel
2026-10-31 20:26:15 +08:00

564 lines
17 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.
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
}