423 lines
14 KiB
TypeScript
423 lines
14 KiB
TypeScript
import {
|
|
ArrowLeftOutlined,
|
|
CopyOutlined,
|
|
FileTextOutlined,
|
|
PauseCircleOutlined,
|
|
PlayCircleOutlined,
|
|
StopOutlined,
|
|
} from '@ant-design/icons'
|
|
import {
|
|
Alert,
|
|
App,
|
|
Button,
|
|
Card,
|
|
Descriptions,
|
|
Input,
|
|
Modal,
|
|
Skeleton,
|
|
Space,
|
|
Table,
|
|
Tag,
|
|
Typography,
|
|
} from 'antd'
|
|
import type { TableColumnsType } from 'antd'
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import { useEffect, useState } from 'react'
|
|
import { useNavigate, useParams, useSearchParams } from 'react-router'
|
|
|
|
import JsonPreview from '@/components/admin/JsonPreview'
|
|
import ManualOrderCostModal from '@/components/admin/ManualOrderCostModal'
|
|
import PageHeader from '@/components/admin/PageHeader'
|
|
import StatusTag from '@/components/admin/StatusTag'
|
|
import { fetchAdminOrderDetail, updateAdminOrderClaimStatus } from '@/services/admin'
|
|
import type { AdminOrderDetail, AdminTaskListItem } from '@/types/admin'
|
|
import { formatAdminDateTime } from '@/utils/admin-time'
|
|
import { resolveOrderFulfillmentMode, resolveVoucherStateLabel } from '@/utils/admin-display'
|
|
|
|
export default function AdminOrderDetailPage() {
|
|
const navigate = useNavigate()
|
|
const queryClient = useQueryClient()
|
|
const { message, modal } = App.useApp()
|
|
const { orderId = '' } = useParams()
|
|
const [searchParams, setSearchParams] = useSearchParams()
|
|
const [statusModalVisible, setStatusModalVisible] = useState(false)
|
|
const [manualCostOpen, setManualCostOpen] = useState(false)
|
|
const [targetStatus, setTargetStatus] = useState<'active' | 'paused' | 'closed'>('active')
|
|
const [statusReason, setStatusReason] = useState('')
|
|
|
|
useEffect(() => {
|
|
if (searchParams.get('manualCost') === '1') {
|
|
setManualCostOpen(true)
|
|
}
|
|
}, [searchParams])
|
|
|
|
function closeManualCost() {
|
|
setManualCostOpen(false)
|
|
if (searchParams.get('manualCost')) {
|
|
const next = new URLSearchParams(searchParams)
|
|
next.delete('manualCost')
|
|
setSearchParams(next, { replace: true })
|
|
}
|
|
}
|
|
|
|
const query = useQuery({
|
|
queryKey: ['admin-order-detail', orderId],
|
|
queryFn: () => fetchAdminOrderDetail(orderId),
|
|
enabled: Boolean(orderId),
|
|
})
|
|
const detail = query.data?.data
|
|
|
|
const statusMutation = useMutation({
|
|
mutationFn: async (data: { status: 'active' | 'paused' | 'closed'; reason?: string }) => {
|
|
const targetOrderId = orderId || detail?.order?.orderId
|
|
if (!targetOrderId) throw new Error('订单 ID 缺失')
|
|
return updateAdminOrderClaimStatus(targetOrderId, data)
|
|
},
|
|
onSuccess: () => {
|
|
message.success('订单兑换状态已更新')
|
|
setStatusModalVisible(false)
|
|
setStatusReason('')
|
|
void queryClient.invalidateQueries({ queryKey: ['admin-order-detail', orderId] })
|
|
},
|
|
onError: (err: unknown) => {
|
|
message.error(err instanceof Error ? err.message : '更新状态失败')
|
|
},
|
|
})
|
|
|
|
const handleCopyH5Link = () => {
|
|
if (!detail?.order.platformOrderId) return
|
|
const h5Url = `${window.location.origin}/h5/${encodeURIComponent(detail.order.platformOrderId)}`
|
|
navigator.clipboard
|
|
.writeText(h5Url)
|
|
.then(() => message.success('已复制 H5 领取链接: ' + h5Url))
|
|
.catch(() => message.error('复制失败,请手动复制'))
|
|
}
|
|
|
|
const openStatusModal = (status: 'active' | 'paused' | 'closed') => {
|
|
if (status === 'active') {
|
|
modal.confirm({
|
|
title: '恢复启用订单兑换',
|
|
content: '恢复后,买家可正常进入 H5 页面提交 UID 领取,确认恢复吗?',
|
|
onOk: async () => {
|
|
await statusMutation.mutateAsync({ status: 'active', reason: '管理员恢复启用' })
|
|
},
|
|
})
|
|
return
|
|
}
|
|
setTargetStatus(status)
|
|
setStatusReason('')
|
|
setStatusModalVisible(true)
|
|
}
|
|
|
|
if (query.isLoading) {
|
|
return (
|
|
<section className="page-stack">
|
|
<PageHeader title="订单详情" extra={<BackButton />} />
|
|
<Card>
|
|
<Skeleton active paragraph={{ rows: 10 }} />
|
|
</Card>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
if (query.error || !detail) {
|
|
return (
|
|
<section className="page-stack">
|
|
<PageHeader title="订单详情" extra={<BackButton />} />
|
|
<Alert
|
|
type="error"
|
|
showIcon
|
|
message={query.error instanceof Error ? query.error.message : '订单不存在或读取失败'}
|
|
/>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
const taskColumns: TableColumnsType<AdminTaskListItem> = [
|
|
{
|
|
title: '任务号',
|
|
dataIndex: 'taskNo',
|
|
minWidth: 180,
|
|
render: (_, row) => (
|
|
<Typography.Link onClick={() => navigate(`/admin/tasks/${row.taskId}`)}>
|
|
{row.taskNo}
|
|
</Typography.Link>
|
|
),
|
|
},
|
|
{
|
|
title: '状态',
|
|
minWidth: 220,
|
|
render: (_, row) => (
|
|
<Space size={[0, 6]} wrap>
|
|
<StatusTag value={row.status} kind="task" />
|
|
<StatusTag value={row.resourceStatus} kind="resource" />
|
|
<StatusTag value={row.customerStatus} kind="customer" />
|
|
</Space>
|
|
),
|
|
},
|
|
{
|
|
title: '角色',
|
|
minWidth: 160,
|
|
render: (_, row) => (
|
|
<div className="cell-stack">
|
|
<span>{row.roleName || '-'}</span>
|
|
<span className="muted">{row.roleId || '-'}</span>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
title: '错误',
|
|
dataIndex: 'lastError',
|
|
minWidth: 220,
|
|
render: (value) => <span className="muted">{value || '-'}</span>,
|
|
},
|
|
]
|
|
|
|
const allTasksCompleted =
|
|
detail.tasks.length > 0 &&
|
|
detail.tasks.every(
|
|
(t) =>
|
|
['completed', 'redeemed', 'delivered', 'success'].includes(t.status) ||
|
|
t.deliveryStatus === 'delivered',
|
|
)
|
|
const isOrderFulfilled = detail.order.orderStatus === 'completed' || allTasksCompleted
|
|
const isOrderRefundedOrCancelled =
|
|
detail.order.payStatus === 'refunded' || detail.order.orderStatus === 'cancelled'
|
|
const canPauseOrClose = !isOrderFulfilled && !isOrderRefundedOrCancelled
|
|
const isAutoClaimOrder = Boolean(
|
|
detail.order.fulfillmentMode === 'affiliate_dash' ||
|
|
(detail.order.fulfillmentMode === 'mixed' && detail.order.canClaimH5),
|
|
)
|
|
const fulfillmentMode = resolveOrderFulfillmentMode(detail.order)
|
|
const voucherLabel = resolveVoucherStateLabel(detail.order.voucherState)
|
|
|
|
return (
|
|
<section className="page-stack">
|
|
<PageHeader
|
|
title="订单详情"
|
|
description={detail.order.platformOrderId}
|
|
extra={<BackButton />}
|
|
/>
|
|
|
|
<Card
|
|
title="基础信息"
|
|
extra={
|
|
<Space>
|
|
<Button icon={<FileTextOutlined />} onClick={() => setManualCostOpen(true)}>
|
|
人工录单
|
|
</Button>
|
|
{isAutoClaimOrder ? (
|
|
<>
|
|
<Button icon={<CopyOutlined />} onClick={handleCopyH5Link}>
|
|
复制 H5 领取链接
|
|
</Button>
|
|
{detail.order.claimStatus === 'paused' || detail.order.claimStatus === 'closed' ? (
|
|
<Button
|
|
type="primary"
|
|
icon={<PlayCircleOutlined />}
|
|
onClick={() => openStatusModal('active')}
|
|
loading={statusMutation.isPending}
|
|
>
|
|
恢复启用
|
|
</Button>
|
|
) : canPauseOrClose ? (
|
|
<Button
|
|
danger
|
|
icon={<PauseCircleOutlined />}
|
|
onClick={() => openStatusModal('paused')}
|
|
loading={statusMutation.isPending}
|
|
>
|
|
暂停兑换
|
|
</Button>
|
|
) : null}
|
|
{canPauseOrClose && detail.order.claimStatus !== 'closed' ? (
|
|
<Button
|
|
danger
|
|
icon={<StopOutlined />}
|
|
onClick={() => openStatusModal('closed')}
|
|
loading={statusMutation.isPending}
|
|
>
|
|
关闭兑换
|
|
</Button>
|
|
) : null}
|
|
</>
|
|
) : null}
|
|
</Space>
|
|
}
|
|
>
|
|
<Descriptions column={{ xs: 1, md: 2, xl: 3 }} bordered size="small">
|
|
<Descriptions.Item label="订单 ID">{detail.order.orderId}</Descriptions.Item>
|
|
<Descriptions.Item label="店铺">
|
|
{detail.order.shopName || detail.order.shopId || '-'}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="平台订单号">{detail.order.platformOrderId}</Descriptions.Item>
|
|
<Descriptions.Item label="履约模式">
|
|
<Tag color={fulfillmentMode.color} style={{ margin: 0 }}>
|
|
{fulfillmentMode.label}
|
|
</Tag>
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="核销码">
|
|
{detail.order.hasVoucher && voucherLabel ? (
|
|
<Tag color="gold" style={{ margin: 0 }}>
|
|
🎫 {voucherLabel}
|
|
</Tag>
|
|
) : (
|
|
<span className="muted">无</span>
|
|
)}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="订单状态">
|
|
<StatusTag value={detail.order.orderStatus} />
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="支付状态">
|
|
<StatusTag value={detail.order.payStatus} kind="pay" />
|
|
</Descriptions.Item>
|
|
{isAutoClaimOrder ? (
|
|
<Descriptions.Item label="H5 兑换状态">
|
|
<Space wrap>
|
|
{detail.order.claimStatus === 'paused' ? (
|
|
<Tag color="warning" icon={<PauseCircleOutlined />}>
|
|
已暂停兑换
|
|
</Tag>
|
|
) : detail.order.claimStatus === 'closed' ? (
|
|
<Tag color="default" icon={<StopOutlined />}>
|
|
已关闭兑换
|
|
</Tag>
|
|
) : (
|
|
<Tag color="success" icon={<PlayCircleOutlined />}>
|
|
正常有效
|
|
</Tag>
|
|
)}
|
|
{detail.order.claimStatusReason ? (
|
|
<span className="muted" style={{ fontSize: 12 }}>
|
|
({detail.order.claimStatusReason})
|
|
</span>
|
|
) : null}
|
|
</Space>
|
|
</Descriptions.Item>
|
|
) : null}
|
|
<Descriptions.Item label="金额">
|
|
{detail.order.totalAmount} {detail.order.currency}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="创建时间">
|
|
{formatAdminDateTime(detail.order.createdAt)}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="更新时间">
|
|
{formatAdminDateTime(detail.order.updatedAt)}
|
|
</Descriptions.Item>
|
|
<Descriptions.Item label="商品摘要">
|
|
<div className="cell-stack" style={{ gap: 4 }}>
|
|
<span>{detail.order.itemTitle || detail.order.itemSummary || '-'}</span>
|
|
{detail.order.skuNick ? (
|
|
<Tag color="blue" style={{ margin: 0, width: 'fit-content' }}>
|
|
{detail.order.skuNick}
|
|
</Tag>
|
|
) : null}
|
|
</div>
|
|
</Descriptions.Item>
|
|
</Descriptions>
|
|
</Card>
|
|
|
|
<Card title="商品明细">
|
|
<Table<AdminOrderDetail['items'][number]>
|
|
rowKey="orderItemId"
|
|
pagination={false}
|
|
dataSource={detail.items}
|
|
scroll={{ x: 760 }}
|
|
columns={[
|
|
{
|
|
title: '商品',
|
|
dataIndex: 'itemTitle',
|
|
minWidth: 240,
|
|
render: (value) => <strong>{value || '-'}</strong>,
|
|
},
|
|
{
|
|
title: 'SKU / 规格',
|
|
minWidth: 180,
|
|
render: (_, item) => (
|
|
<div className="cell-stack" style={{ gap: 4 }}>
|
|
{item.skuNick ? (
|
|
<Tag color="blue" style={{ margin: 0, width: 'fit-content' }}>
|
|
{item.skuNick}
|
|
</Tag>
|
|
) : null}
|
|
{/* 商品列已是内部 SKU 时不再重复展示同一串 */}
|
|
{item.skuCode && item.skuCode !== item.itemTitle ? (
|
|
<span className="muted">{item.skuCode}</span>
|
|
) : null}
|
|
</div>
|
|
),
|
|
},
|
|
{ title: '数量', dataIndex: 'quantity', width: 90 },
|
|
{ title: '履约模式', dataIndex: 'deliveryMode', minWidth: 140 },
|
|
]}
|
|
/>
|
|
</Card>
|
|
|
|
<Card title="任务">
|
|
<Table<AdminTaskListItem>
|
|
rowKey="taskId"
|
|
pagination={false}
|
|
dataSource={detail.tasks}
|
|
columns={taskColumns}
|
|
scroll={{ x: 900 }}
|
|
/>
|
|
</Card>
|
|
|
|
<Card title="原始载荷">
|
|
<JsonPreview value={detail.order.rawPayload} />
|
|
</Card>
|
|
|
|
<Modal
|
|
title={targetStatus === 'paused' ? '暂停订单兑换' : '关闭订单兑换'}
|
|
open={statusModalVisible}
|
|
onCancel={() => setStatusModalVisible(false)}
|
|
confirmLoading={statusMutation.isPending}
|
|
onOk={() => {
|
|
if (!statusReason.trim()) {
|
|
message.warning('请填写原因或备注(如:买家申请退款)')
|
|
return
|
|
}
|
|
statusMutation.mutate({ status: targetStatus, reason: statusReason.trim() })
|
|
}}
|
|
>
|
|
<div style={{ padding: '12px 0' }}>
|
|
<p style={{ color: '#64748b', marginBottom: 8 }}>
|
|
{targetStatus === 'paused'
|
|
? '暂停后,买家访问 H5 链接将被拦截并提示“兑换已暂停,请联系客服”,后续可随时恢复启用。'
|
|
: '关闭后,买家将无法继续进入 H5 页面兑换,适用于已退款或订单作废。'}
|
|
</p>
|
|
<Input.TextArea
|
|
rows={3}
|
|
placeholder="请输入暂停/关闭原因(必填,如:买家退款、售后争议排查)"
|
|
value={statusReason}
|
|
onChange={(e) => setStatusReason(e.target.value)}
|
|
/>
|
|
</div>
|
|
</Modal>
|
|
|
|
<ManualOrderCostModal
|
|
open={manualCostOpen}
|
|
orderId={detail.order.orderId}
|
|
orderNo={detail.order.platformOrderId || String(detail.order.orderId)}
|
|
items={detail.items.map((item) => ({
|
|
orderItemId: item.orderItemId,
|
|
matchSku: item.matchSku,
|
|
skuName: item.skuName,
|
|
skuCode: item.skuCode,
|
|
quantity: item.quantity,
|
|
}))}
|
|
onClose={closeManualCost}
|
|
/>
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function BackButton() {
|
|
const navigate = useNavigate()
|
|
return (
|
|
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/admin/orders')}>
|
|
返回订单
|
|
</Button>
|
|
)
|
|
}
|