- 迁移 112:order_refunds(订单项级退款,整单拆多条共享 batch_no,source/external_ref 预留平台 API)+ cost_exceptions.resolution_note - 新服务 order-refund-service:整单/指定商品退款、可退金额校验、撤销、全额退完同步 pay_status - 销售事实改为按有效退款记录冲减;无记录时仍兼容 pay_status=refunded 的历史全额退款 - 订单详情新增退款登记与退款记录表,商品明细显示收入/已退/可退 - 全额退款自动关闭该订单项成本异常并写处理备注;成本异常处理改为带备注弹窗 - 财务事件 UNION 接入退款记录,文档同步 P2 状态与 API
771 lines
27 KiB
TypeScript
771 lines
27 KiB
TypeScript
import {
|
||
ArrowLeftOutlined,
|
||
CopyOutlined,
|
||
FileTextOutlined,
|
||
PauseCircleOutlined,
|
||
PlayCircleOutlined,
|
||
RollbackOutlined,
|
||
StopOutlined,
|
||
} from '@ant-design/icons'
|
||
import {
|
||
Alert,
|
||
App,
|
||
Button,
|
||
Card,
|
||
DatePicker,
|
||
Descriptions,
|
||
Form,
|
||
Input,
|
||
InputNumber,
|
||
Modal,
|
||
Radio,
|
||
Select,
|
||
Skeleton,
|
||
Space,
|
||
Tag,
|
||
Typography,
|
||
} from 'antd'
|
||
import PercentTable from '@/components/common/PercentTable'
|
||
import type { TableColumnsType } from 'antd'
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||
import type { Dayjs } from 'dayjs'
|
||
import { useEffect, useState } from 'react'
|
||
import { useNavigate, useParams, useSearchParams } from 'react-router'
|
||
|
||
import dayjs from '@/lib/dayjs'
|
||
|
||
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 {
|
||
createAdminOrderRefund,
|
||
fetchAdminOrderDetail,
|
||
updateAdminOrderClaimStatus,
|
||
voidAdminOrderRefund,
|
||
} from '@/services/admin'
|
||
import type { AdminOrderDetail, AdminOrderRefund, AdminTaskListItem } from '@/types/admin'
|
||
import { hasAdminPermission } from '@/utils/admin-auth'
|
||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||
import { resolveOrderFulfillmentMode, resolveVoucherStateLabel } from '@/utils/admin-display'
|
||
|
||
import { CostPreviewModal } from './cost-center/CostPreviewModal'
|
||
import { formatMoney } from './panels/shared'
|
||
|
||
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 [costPreviewItemId, setCostPreviewItemId] = useState<number | null>(null)
|
||
const [refundModalOpen, setRefundModalOpen] = useState(false)
|
||
const [refundScope, setRefundScope] = useState<'order' | 'item'>('order')
|
||
const [refundItemId, setRefundItemId] = useState<number | null>(null)
|
||
const [refundAmountYuan, setRefundAmountYuan] = useState<number | null>(null)
|
||
const [refundReason, setRefundReason] = useState('买家退款')
|
||
const [refundNote, setRefundNote] = useState('')
|
||
const [refundedAt, setRefundedAt] = useState<Dayjs>(dayjs())
|
||
const [refunding, setRefunding] = useState(false)
|
||
const [voidTarget, setVoidTarget] = useState<AdminOrderRefund | null>(null)
|
||
const [voidReason, setVoidReason] = useState('')
|
||
const [voiding, setVoiding] = 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)
|
||
}
|
||
|
||
function openRefundModal(itemId?: number) {
|
||
const items = detail?.items || []
|
||
const target = itemId ? items.find((item) => item.orderItemId === itemId) : null
|
||
const fallback = items.find((item) => item.refundableFen > 0) || null
|
||
const focus = target || fallback
|
||
setRefundScope(target ? 'item' : 'order')
|
||
setRefundItemId(focus?.orderItemId ?? null)
|
||
setRefundAmountYuan(focus ? focus.refundableFen / 100 : 0)
|
||
setRefundReason('买家退款')
|
||
setRefundNote('')
|
||
setRefundedAt(dayjs())
|
||
setRefundModalOpen(true)
|
||
}
|
||
|
||
async function submitRefund() {
|
||
if (!detail) return
|
||
if (refundScope === 'item' && !refundItemId) {
|
||
message.warning('请选择要退款的商品')
|
||
return
|
||
}
|
||
const amountFen = Math.round(Number(refundAmountYuan || 0) * 100)
|
||
if (refundScope === 'item' && amountFen <= 0) {
|
||
message.warning('请输入退款金额')
|
||
return
|
||
}
|
||
setRefunding(true)
|
||
try {
|
||
const response = await createAdminOrderRefund(detail.order.orderId, {
|
||
orderItemId: refundScope === 'item' ? refundItemId || undefined : undefined,
|
||
amountFen: refundScope === 'item' ? amountFen : undefined,
|
||
reason: refundReason.trim() || '手动退款',
|
||
note: refundNote.trim(),
|
||
refundedAt: refundedAt.format('YYYY-MM-DD HH:mm:ss'),
|
||
})
|
||
const result = response.data
|
||
message.success(
|
||
`已登记 ${result.refunds.length} 条退款${result.fullRefunded ? '(订单已全额退款)' : ''}${
|
||
result.closedExceptionCount > 0
|
||
? `,自动关闭 ${result.closedExceptionCount} 条成本异常`
|
||
: ''
|
||
}`,
|
||
)
|
||
setRefundModalOpen(false)
|
||
await queryClient.invalidateQueries({ queryKey: ['admin-order-detail', orderId] })
|
||
await queryClient.invalidateQueries({ queryKey: ['admin-finance'] })
|
||
} catch (error) {
|
||
message.error(error instanceof Error ? error.message : '登记退款失败')
|
||
} finally {
|
||
setRefunding(false)
|
||
}
|
||
}
|
||
|
||
async function submitVoidRefund() {
|
||
if (!detail || !voidTarget) return
|
||
setVoiding(true)
|
||
try {
|
||
await voidAdminOrderRefund(detail.order.orderId, voidTarget.id, {
|
||
reason: voidReason.trim() || '撤销退款',
|
||
})
|
||
message.success('退款已撤销')
|
||
setVoidTarget(null)
|
||
setVoidReason('')
|
||
await queryClient.invalidateQueries({ queryKey: ['admin-order-detail', orderId] })
|
||
await queryClient.invalidateQueries({ queryKey: ['admin-finance'] })
|
||
} catch (error) {
|
||
message.error(error instanceof Error ? error.message : '撤销退款失败')
|
||
} finally {
|
||
setVoiding(false)
|
||
}
|
||
}
|
||
|
||
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>
|
||
{hasAdminPermission('finance.config') ? (
|
||
<Button
|
||
danger
|
||
icon={<RollbackOutlined />}
|
||
disabled={!detail.items.some((item) => item.refundableFen > 0)}
|
||
onClick={() => openRefundModal()}
|
||
>
|
||
退款登记
|
||
</Button>
|
||
) : null}
|
||
<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="商品明细">
|
||
<PercentTable<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: '收入 / 已退',
|
||
width: 190,
|
||
render: (_, item) => (
|
||
<div className="cell-stack" style={{ gap: 4 }}>
|
||
<span>{formatMoney(item.revenueFen)}</span>
|
||
<span className="muted">
|
||
已退 {formatMoney(item.refundedFen)} · 可退 {formatMoney(item.refundableFen)}
|
||
</span>
|
||
</div>
|
||
),
|
||
},
|
||
{ title: '履约模式', dataIndex: 'deliveryMode', minWidth: 140 },
|
||
{
|
||
title: '成本',
|
||
width: 110,
|
||
render: (_, item) => (
|
||
<Button size="small" onClick={() => setCostPreviewItemId(item.orderItemId)}>
|
||
查成本
|
||
</Button>
|
||
),
|
||
},
|
||
]}
|
||
/>
|
||
</Card>
|
||
|
||
{detail.refunds.length > 0 ? (
|
||
<Card
|
||
title={
|
||
<Space size={6}>
|
||
<Typography.Text strong>退款记录</Typography.Text>
|
||
<Tag color="red">
|
||
{formatMoney(
|
||
detail.refunds
|
||
.filter((item) => item.status === 'confirmed')
|
||
.reduce((sum, item) => sum + item.amountFen, 0),
|
||
)}
|
||
</Tag>
|
||
</Space>
|
||
}
|
||
>
|
||
<PercentTable<AdminOrderRefund>
|
||
rowKey="id"
|
||
pagination={false}
|
||
dataSource={detail.refunds}
|
||
scroll={{ x: 980 }}
|
||
columns={[
|
||
{
|
||
title: '退款单号',
|
||
dataIndex: 'refundNo',
|
||
width: 190,
|
||
render: (value: string, row) => (
|
||
<div className="cell-stack" style={{ gap: 2 }}>
|
||
<span>{value}</span>
|
||
<span className="muted">{row.batchNo}</span>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
title: '商品',
|
||
dataIndex: 'skuName',
|
||
width: 240,
|
||
render: (value: string) => value || '-',
|
||
},
|
||
{
|
||
title: '金额',
|
||
dataIndex: 'amountFen',
|
||
width: 120,
|
||
render: (value: number) => formatMoney(value),
|
||
},
|
||
{
|
||
title: '原因',
|
||
dataIndex: 'reason',
|
||
width: 220,
|
||
render: (value: string, row) => (
|
||
<div className="cell-stack" style={{ gap: 2 }}>
|
||
<span>{value || '-'}</span>
|
||
{row.note ? <span className="muted">{row.note}</span> : null}
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
title: '退款时间',
|
||
dataIndex: 'refundedAt',
|
||
width: 180,
|
||
render: (value: string) => formatAdminDateTime(value),
|
||
},
|
||
{
|
||
title: '操作人',
|
||
dataIndex: 'operatorName',
|
||
width: 120,
|
||
render: (value: string) => value || '-',
|
||
},
|
||
{
|
||
title: '状态',
|
||
dataIndex: 'status',
|
||
width: 100,
|
||
render: (value: string, row) =>
|
||
value === 'confirmed' ? (
|
||
<Tag color="green">已确认</Tag>
|
||
) : (
|
||
<Tag color="default">已撤销{row.voidReason ? `:${row.voidReason}` : ''}</Tag>
|
||
),
|
||
},
|
||
{
|
||
title: '操作',
|
||
width: 90,
|
||
render: (_, row) =>
|
||
hasAdminPermission('finance.config') && row.status === 'confirmed' ? (
|
||
<Button size="small" danger onClick={() => setVoidTarget(row)}>
|
||
撤销
|
||
</Button>
|
||
) : null,
|
||
},
|
||
]}
|
||
/>
|
||
</Card>
|
||
) : null}
|
||
|
||
<Card title="任务">
|
||
<PercentTable<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}
|
||
/>
|
||
|
||
<Modal
|
||
title="退款登记"
|
||
open={refundModalOpen}
|
||
onCancel={() => setRefundModalOpen(false)}
|
||
onOk={() => void submitRefund()}
|
||
confirmLoading={refunding}
|
||
okText="确认退款"
|
||
cancelText="取消"
|
||
width={560}
|
||
>
|
||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||
<Alert
|
||
type="info"
|
||
showIcon
|
||
message={`当前可退合计 ${formatMoney(
|
||
detail.items.reduce((sum, item) => sum + item.refundableFen, 0),
|
||
)}`}
|
||
description="整单退款会按每个商品剩余可退金额拆成多条记录;登记后当天销售事实立即重算。"
|
||
/>
|
||
<Radio.Group
|
||
value={refundScope}
|
||
onChange={(event) => {
|
||
const next = event.target.value as 'order' | 'item'
|
||
setRefundScope(next)
|
||
if (next === 'item') {
|
||
const focus = detail.items.find((item) => item.refundableFen > 0) || null
|
||
setRefundItemId(focus?.orderItemId ?? null)
|
||
setRefundAmountYuan(focus ? focus.refundableFen / 100 : 0)
|
||
}
|
||
}}
|
||
>
|
||
<Radio.Button value="order">整单退款</Radio.Button>
|
||
<Radio.Button value="item">指定商品</Radio.Button>
|
||
</Radio.Group>
|
||
{refundScope === 'item' ? (
|
||
<>
|
||
<Select
|
||
value={refundItemId ?? undefined}
|
||
onChange={(value) => {
|
||
setRefundItemId(value)
|
||
const item = detail.items.find((row) => row.orderItemId === value)
|
||
setRefundAmountYuan(item ? item.refundableFen / 100 : 0)
|
||
}}
|
||
options={detail.items
|
||
.filter((item) => item.refundableFen > 0)
|
||
.map((item) => ({
|
||
value: item.orderItemId,
|
||
label: `${item.itemTitle || item.skuName} · 可退 ${formatMoney(item.refundableFen)}`,
|
||
}))}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
<InputNumber
|
||
addonBefore="¥"
|
||
min={0.01}
|
||
precision={2}
|
||
value={refundAmountYuan}
|
||
onChange={(value) => setRefundAmountYuan(value ?? 0)}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
</>
|
||
) : (
|
||
<Typography.Text type="secondary">
|
||
将按 {detail.items.filter((item) => item.refundableFen > 0).length}{' '}
|
||
个有可退金额的商品逐条登记。
|
||
</Typography.Text>
|
||
)}
|
||
<Input
|
||
value={refundReason}
|
||
onChange={(event) => setRefundReason(event.target.value)}
|
||
placeholder="退款原因,如:买家退款 / 售后补偿 / 发错货"
|
||
/>
|
||
<DatePicker
|
||
showTime
|
||
value={refundedAt}
|
||
onChange={(value) => {
|
||
if (value) setRefundedAt(value)
|
||
}}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
<Input.TextArea
|
||
rows={2}
|
||
value={refundNote}
|
||
onChange={(event) => setRefundNote(event.target.value)}
|
||
placeholder="备注(可选,如平台退款单号、沟通记录)"
|
||
/>
|
||
</Space>
|
||
</Modal>
|
||
|
||
<Modal
|
||
title="撤销退款"
|
||
open={voidTarget !== null}
|
||
onCancel={() => {
|
||
setVoidTarget(null)
|
||
setVoidReason('')
|
||
}}
|
||
onOk={() => void submitVoidRefund()}
|
||
confirmLoading={voiding}
|
||
okText="确认撤销"
|
||
okButtonProps={{ danger: true }}
|
||
cancelText="取消"
|
||
width={520}
|
||
>
|
||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||
<Alert
|
||
type="warning"
|
||
showIcon
|
||
message={`撤销 ${voidTarget?.refundNo || ''}(${formatMoney(voidTarget?.amountFen || 0)})`}
|
||
description="撤销后销售事实会按剩余有效退款重算;已置为「已退款」的订单支付状态不会自动回退。"
|
||
/>
|
||
<Input
|
||
value={voidReason}
|
||
onChange={(event) => setVoidReason(event.target.value)}
|
||
placeholder="撤销原因(可选)"
|
||
/>
|
||
</Space>
|
||
</Modal>
|
||
|
||
<CostPreviewModal
|
||
open={costPreviewItemId !== null}
|
||
orderItemId={costPreviewItemId}
|
||
onClose={() => setCostPreviewItemId(null)}
|
||
/>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function BackButton() {
|
||
const navigate = useNavigate()
|
||
return (
|
||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/admin/orders')}>
|
||
返回订单
|
||
</Button>
|
||
)
|
||
}
|