Files
order_site/apps/frontend/src/pages/admin/AdminOrdersPage.tsx
T
yml2213 9a6b4313a8 fix(order): 移除订单列表冗长的买家信息列,优化多平台表格密度
- 移除订单列表中的「买家信息」列,消除抖音等虚拟品类中超长加密 open_id / 匿名买家造成的垂直与水平排版挤压
- 紧凑化表格宽度(scroll 960px),提升列表浏览效率
- 详情页继续保留完整买家原始载荷供追溯

验证方式:前端类型检查及 132 项单测全通过,前端打包构建通过
2026-10-10 19:04:09 +08:00

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
}