- 移除订单列表中的「买家信息」列,消除抖音等虚拟品类中超长加密 open_id / 匿名买家造成的垂直与水平排版挤压 - 紧凑化表格宽度(scroll 960px),提升列表浏览效率 - 详情页继续保留完整买家原始载荷供追溯 验证方式:前端类型检查及 132 项单测全通过,前端打包构建通过
562 lines
18 KiB
TypeScript
562 lines
18 KiB
TypeScript
import {
|
|
CopyOutlined,
|
|
EyeOutlined,
|
|
LinkOutlined,
|
|
ReloadOutlined,
|
|
SearchOutlined,
|
|
ShopOutlined,
|
|
} from '@ant-design/icons'
|
|
import {
|
|
App,
|
|
Button,
|
|
Card,
|
|
Form,
|
|
Input,
|
|
Segmented,
|
|
Select,
|
|
Space,
|
|
Table,
|
|
Tag,
|
|
Tooltip,
|
|
Typography,
|
|
} from 'antd'
|
|
import type { TableColumnsType } from 'antd'
|
|
import { useQuery } from '@tanstack/react-query'
|
|
import { useEffect, useMemo } from 'react'
|
|
import { useNavigate, useSearchParams } from 'react-router'
|
|
|
|
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
|
|
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,
|
|
} from '@/utils/admin-display'
|
|
import {
|
|
adminOrderExecutorOptions,
|
|
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
|
|
executorKey?: string
|
|
payStatus?: string
|
|
taskStatus?: string
|
|
dateRange?: unknown
|
|
}
|
|
|
|
/** 默认展示近 7 天(含今天)。 */
|
|
function resolveDefaultOrderDateRange() {
|
|
const today = dayjs()
|
|
return {
|
|
dateFrom: today.subtract(6, 'day').format('YYYY-MM-DD'),
|
|
dateTo: today.format('YYYY-MM-DD'),
|
|
}
|
|
}
|
|
|
|
export default function AdminOrdersPage() {
|
|
const navigate = useNavigate()
|
|
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)
|
|
|
|
// 首次进入(仅有分页参数)时写入默认近 7 天,避免一把拉出全库。
|
|
useEffect(() => {
|
|
const meaningfulKeys = [...searchParams.keys()].filter(
|
|
(key) => key !== 'page' && key !== 'pageSize',
|
|
)
|
|
if (meaningfulKeys.length > 0) {
|
|
return
|
|
}
|
|
const { dateFrom, dateTo } = resolveDefaultOrderDateRange()
|
|
setSearchParams(cleanParams({ page: 1, pageSize, dateFrom, dateTo }), { replace: true })
|
|
// 仅首屏引导默认日期,避免与后续筛选互相打架。
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [])
|
|
|
|
const filters = {
|
|
keyword: searchParams.get('keyword') || '',
|
|
provider: searchParams.get('provider') || '',
|
|
executorKey: searchParams.get('executorKey') || '',
|
|
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.executorKey,
|
|
filters.keyword,
|
|
filters.payStatus,
|
|
filters.provider,
|
|
filters.taskStatus,
|
|
page,
|
|
pageSize,
|
|
],
|
|
)
|
|
|
|
// 等待默认日期写入 URL 后再请求,避免首屏无日期打出全库。
|
|
const hasBootstrappedFilters =
|
|
Boolean(filters.dateFrom && filters.dateTo) ||
|
|
Boolean(filters.keyword) ||
|
|
searchParams.get('allTime') === '1' ||
|
|
Boolean(filters.provider || filters.executorKey || filters.payStatus || filters.taskStatus)
|
|
|
|
const query = useQuery({
|
|
queryKey: ['admin-orders', queryParams],
|
|
queryFn: () => fetchAdminOrders(queryParams),
|
|
enabled: hasBootstrappedFilters,
|
|
})
|
|
const data = query.data?.data
|
|
|
|
useEffect(() => {
|
|
form.setFieldsValue({
|
|
keyword: filters.keyword || undefined,
|
|
provider: filters.provider || undefined,
|
|
executorKey: filters.executorKey || 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.executorKey,
|
|
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: '商品 / 规格',
|
|
minWidth: 260,
|
|
render: (_, row) => {
|
|
const itemTitle = row.itemTitle || row.itemSummary || '-'
|
|
const skuNick = row.skuNick
|
|
const items = row.items && row.items.length > 0 ? row.items : null
|
|
|
|
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) => (
|
|
<div key={idx} className="cell-stack" style={{ gap: 2 }}>
|
|
<Typography.Text
|
|
strong
|
|
ellipsis={{ tooltip: item.itemTitle }}
|
|
style={{ maxWidth: 360, fontSize: 13 }}
|
|
>
|
|
{item.itemTitle || '-'}
|
|
</Typography.Text>
|
|
{item.skuNick ? (
|
|
<Space size={4} wrap>
|
|
<Tag
|
|
color="blue"
|
|
style={{
|
|
margin: 0,
|
|
maxWidth: 320,
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
fontSize: 12,
|
|
}}
|
|
title={`规格:${item.skuNick}`}
|
|
>
|
|
{item.skuNick}
|
|
</Tag>
|
|
</Space>
|
|
) : null}
|
|
</div>
|
|
))}
|
|
{items.length > 2 ? <span className="muted">等 {items.length} 种商品</span> : null}
|
|
</div>
|
|
) : (
|
|
<>
|
|
<Typography.Text
|
|
strong
|
|
ellipsis={{ tooltip: itemTitle }}
|
|
style={{ maxWidth: 360, fontSize: 13 }}
|
|
>
|
|
{itemTitle}
|
|
</Typography.Text>
|
|
{skuNick ? (
|
|
<Space size={4} wrap>
|
|
<Tag
|
|
color="blue"
|
|
style={{
|
|
margin: 0,
|
|
maxWidth: 320,
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
whiteSpace: 'nowrap',
|
|
fontSize: 12,
|
|
}}
|
|
title={`规格:${skuNick}`}
|
|
>
|
|
{skuNick}
|
|
</Tag>
|
|
</Space>
|
|
) : null}
|
|
</>
|
|
)}
|
|
<span className="muted" style={{ fontSize: 12 }}>
|
|
数量 {row.totalQuantity || 1} · 关联任务 {row.taskCount || 0}
|
|
</span>
|
|
</div>
|
|
)
|
|
},
|
|
},
|
|
{
|
|
title: '交付 / 状态',
|
|
minWidth: 230,
|
|
render: (_, row) => {
|
|
const fulfillmentMode = resolveOrderFulfillmentMode(row)
|
|
|
|
return (
|
|
<div className="cell-stack" style={{ gap: 4 }}>
|
|
<Space size={[0, 4]} wrap>
|
|
<Tag color={fulfillmentMode.color} style={{ margin: 0, fontSize: 11 }}>
|
|
{fulfillmentMode.label}
|
|
</Tag>
|
|
<StatusTag value={row.payStatus} kind="pay" />
|
|
<StatusTag value={row.resourceStatus} kind="resource" />
|
|
<StatusTag value={row.customerStatus} kind="customer" />
|
|
</Space>
|
|
|
|
{row.claimStatus === 'paused' ? (
|
|
<Tag color="warning" style={{ margin: 0, width: 'fit-content' }}>
|
|
H5已暂停
|
|
</Tag>
|
|
) : 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: 140,
|
|
fixed: 'right',
|
|
render: (_, row) => {
|
|
const orderNo = row.platformOrderId || String(row.orderId)
|
|
const h5Url = `${window.location.origin}/h5/${encodeURIComponent(orderNo)}`
|
|
|
|
return (
|
|
<Space size={6} wrap>
|
|
<Button
|
|
type="primary"
|
|
size="small"
|
|
ghost
|
|
icon={<EyeOutlined />}
|
|
onClick={() => navigate(`/admin/orders/${row.orderId}`)}
|
|
>
|
|
详情
|
|
</Button>
|
|
<Tooltip title="复制 H5 领取链接">
|
|
<Button
|
|
size="small"
|
|
icon={<LinkOutlined />}
|
|
onClick={() => handleCopyText(h5Url, 'H5 领取链接')}
|
|
>
|
|
H5
|
|
</Button>
|
|
</Tooltip>
|
|
</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 || ''
|
|
const allTime = !dateFrom && !dateTo && !keyword ? '1' : ''
|
|
|
|
setSearchParams(
|
|
cleanParams({
|
|
...restValues,
|
|
keyword,
|
|
dateFrom,
|
|
dateTo,
|
|
allTime,
|
|
page: 1,
|
|
pageSize,
|
|
}),
|
|
)
|
|
}
|
|
|
|
function resetFilters() {
|
|
const { dateFrom, dateTo } = resolveDefaultOrderDateRange()
|
|
form.setFieldsValue({
|
|
keyword: undefined,
|
|
provider: undefined,
|
|
executorKey: undefined,
|
|
payStatus: undefined,
|
|
taskStatus: undefined,
|
|
dateRange: [dayjs(dateFrom), dayjs(dateTo)],
|
|
})
|
|
setSearchParams(cleanParams({ page: 1, pageSize, dateFrom, dateTo }))
|
|
}
|
|
|
|
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="executorKey">
|
|
<Select
|
|
allowClear
|
|
placeholder="履约模式"
|
|
options={adminOrderExecutorOptions}
|
|
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>
|
|
<Table<AdminOrderListItem>
|
|
rowKey="orderId"
|
|
loading={query.isLoading || query.isFetching || !hasBootstrappedFilters}
|
|
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,
|
|
allTime: searchParams.get('allTime') || '',
|
|
page: nextPage,
|
|
pageSize: nextPageSize,
|
|
}),
|
|
)
|
|
},
|
|
})}
|
|
/>
|
|
</Card>
|
|
</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
|
|
}
|