feat(admin): 新增后台退款审核队列
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Alert, App, Button, Modal, Radio, Space, Tag, Typography, type TableColumnsType } from 'antd'
|
||||
import { EyeOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
|
||||
import { approveAdminRefund, fetchPendingRefundOrders, rejectAdminRefund } from '@/features/admin/api'
|
||||
import { AdminOrderDetailDrawer } from '@/features/admin/AdminOrderDetailDrawer'
|
||||
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
|
||||
import { DataTable } from '@/features/admin/components/DataTable'
|
||||
import { StatusTag } from '@/features/admin/components/StatusTag'
|
||||
import {
|
||||
handoffStatusMeta,
|
||||
orderStatusMap,
|
||||
readStatusMeta,
|
||||
} from '@/features/admin/labels'
|
||||
import type { AdminOrderDetail } from '@/features/admin/types'
|
||||
import { useAdminSessionStore } from '@/stores/adminSession'
|
||||
import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
|
||||
|
||||
type RejectMode = 'restore' | 'close'
|
||||
|
||||
function buildColumns(
|
||||
onDetail: (row: AdminOrderDetail) => void,
|
||||
onApprove: (row: AdminOrderDetail) => void,
|
||||
onReject: (row: AdminOrderDetail) => void,
|
||||
canClose: boolean,
|
||||
canView: boolean
|
||||
): TableColumnsType<AdminOrderDetail> {
|
||||
return [
|
||||
{
|
||||
title: '订单',
|
||||
dataIndex: 'order_no',
|
||||
width: 220,
|
||||
fixed: 'left',
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack">
|
||||
{canView ? (
|
||||
<button type="button" className="admin-order-link" onClick={() => onDetail(row)}>
|
||||
{row.order_no}
|
||||
</button>
|
||||
) : (
|
||||
<span className="admin-mono">{row.order_no}</span>
|
||||
)}
|
||||
<strong>{row.title}</strong>
|
||||
<span>商品 {formatText(row.listing_no)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '交易双方',
|
||||
key: 'parties',
|
||||
width: 180,
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack">
|
||||
<span>租客 {formatText(row.renter_phone)}</span>
|
||||
<span>号主 {formatText(row.owner_phone)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '退款金额',
|
||||
dataIndex: 'refund_amount_cent',
|
||||
width: 140,
|
||||
align: 'right',
|
||||
render: (_, row) => (
|
||||
<div className="admin-amount">
|
||||
<strong>{formatMoney(row.refund_amount_cent)}</strong>
|
||||
<span>订单 {formatMoney(row.display_amount_cent)}</span>
|
||||
<span>押金 {formatMoney(row.deposit_amount_cent)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '订单状态',
|
||||
key: 'status',
|
||||
width: 170,
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack admin-stack--tiny">
|
||||
<StatusTag meta={readStatusMeta(orderStatusMap, row.status)} />
|
||||
<StatusTag meta={handoffStatusMeta(row.handoff_status, row)} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '申请时间',
|
||||
dataIndex: 'updated_at',
|
||||
width: 170,
|
||||
render: value => formatDateTime(value),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 190,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Space size={4} wrap>
|
||||
{canView ? (
|
||||
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => onDetail(row)}>
|
||||
详情
|
||||
</Button>
|
||||
) : null}
|
||||
{canClose ? (
|
||||
<>
|
||||
<Button type="link" size="small" danger onClick={() => onApprove(row)}>
|
||||
通过退款
|
||||
</Button>
|
||||
<Button type="link" size="small" onClick={() => onReject(row)}>
|
||||
驳回
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function AdminRefundReviewPage() {
|
||||
const { message, modal } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const permissions = useAdminSessionStore(state => state.admin?.permissions)
|
||||
const can = (code: string) => Boolean(permissions?.includes('*') || permissions?.includes(code))
|
||||
const canClose = can('order:close')
|
||||
const canView = can('order:view')
|
||||
const [detailId, setDetailId] = useState<number | null>(null)
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
const [rejectOrder, setRejectOrder] = useState<AdminOrderDetail | null>(null)
|
||||
const [rejectMode, setRejectMode] = useState<RejectMode>('restore')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['admin-refund-pending'],
|
||||
queryFn: fetchPendingRefundOrders,
|
||||
})
|
||||
const items = query.data ?? []
|
||||
|
||||
const refreshAll = async () => {
|
||||
await query.refetch()
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-orders'] })
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-dashboard'] })
|
||||
}
|
||||
|
||||
const openDetail = (row: AdminOrderDetail) => {
|
||||
setDetailId(row.id)
|
||||
setDetailOpen(true)
|
||||
}
|
||||
|
||||
const approve = (row: AdminOrderDetail) => {
|
||||
modal.confirm({
|
||||
title: '通过退款申请?',
|
||||
content: `订单 ${row.order_no} 将原路退款 ${formatMoney(row.refund_amount_cent)},订单随后取消。`,
|
||||
okText: '通过退款',
|
||||
cancelText: '再核对一下',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
try {
|
||||
await approveAdminRefund(row.id)
|
||||
message.success('退款已通过并发起')
|
||||
await refreshAll()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '退款审核失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const openReject = (row: AdminOrderDetail) => {
|
||||
setRejectOrder(row)
|
||||
setRejectMode('restore')
|
||||
}
|
||||
|
||||
const submitReject = async () => {
|
||||
if (!rejectOrder) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await rejectAdminRefund(rejectOrder.id, rejectMode)
|
||||
message.success(rejectMode === 'restore' ? '退款已驳回,订单恢复待交接' : '退款已驳回,订单已关闭')
|
||||
setRejectOrder(null)
|
||||
await refreshAll()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '驳回退款失败')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const columns = buildColumns(openDetail, approve, openReject, canClose, canView)
|
||||
const errorText = query.error instanceof Error ? query.error.message : '请稍后重试或重新登录后台。'
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<AdminPageHeader
|
||||
eyebrow="JIEYOU OPS · 交易"
|
||||
title="退款审核"
|
||||
description="核对退款金额与订单状态后,通过原路退款或驳回并恢复/关闭订单。"
|
||||
meta={
|
||||
<Space size={8} wrap>
|
||||
<Tag color="red">待审核 {items.length} 单</Tag>
|
||||
{query.isFetching ? <Tag color="processing">正在更新</Tag> : null}
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void query.refetch()} loading={query.isFetching}>
|
||||
刷新队列
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{!canClose ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
className="admin-table-alert"
|
||||
message="当前账号没有退款审核权限"
|
||||
description="需要 order:close 权限才能通过或驳回退款。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{query.isError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
className="admin-table-alert"
|
||||
message="退款队列没有加载出来"
|
||||
description={errorText}
|
||||
action={
|
||||
<Button size="small" onClick={() => void query.refetch()}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<DataTable<AdminOrderDetail>
|
||||
title={<span>待审核退款</span>}
|
||||
extra={
|
||||
<Typography.Text type="secondary">
|
||||
{query.isPending ? '加载中' : `共 ${items.length} 单`}
|
||||
</Typography.Text>
|
||||
}
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
loading={query.isFetching}
|
||||
emptyDescription="当前没有待审核退款"
|
||||
onRowClick={canView ? row => openDetail(row) : undefined}
|
||||
/>
|
||||
|
||||
{canView ? (
|
||||
<AdminOrderDetailDrawer
|
||||
orderId={detailId}
|
||||
open={detailOpen}
|
||||
onClose={() => setDetailOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Modal
|
||||
open={rejectOrder !== null}
|
||||
title="驳回退款"
|
||||
okText="确认驳回"
|
||||
cancelText="取消"
|
||||
confirmLoading={submitting}
|
||||
okButtonProps={{ danger: true }}
|
||||
onOk={() => void submitReject()}
|
||||
onCancel={() => {
|
||||
if (!submitting) setRejectOrder(null)
|
||||
}}
|
||||
>
|
||||
<Typography.Paragraph type="secondary">
|
||||
订单 {rejectOrder?.order_no},退款金额 {formatMoney(rejectOrder?.refund_amount_cent)}。
|
||||
请选择驳回后的订单处理方式。
|
||||
</Typography.Paragraph>
|
||||
<Radio.Group
|
||||
value={rejectMode}
|
||||
onChange={event => setRejectMode(event.target.value as RejectMode)}
|
||||
style={{ display: 'flex', flexDirection: 'column', gap: 12 }}
|
||||
>
|
||||
<Radio value="restore">
|
||||
恢复订单至待交接
|
||||
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
|
||||
仅驳回退款,订单继续流转,商品保持占用。
|
||||
</Typography.Text>
|
||||
</Radio>
|
||||
<Radio value="close">
|
||||
驳回退款并关闭订单
|
||||
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
|
||||
关闭订单并归档商品,后续不可再操作。
|
||||
</Typography.Text>
|
||||
</Radio>
|
||||
</Radio.Group>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -217,3 +217,18 @@ export async function adjustAdminUserWallet(
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchPendingRefundOrders() {
|
||||
const { data } = await adminClient.get<ApiResponse<{ items: AdminOrderDetail[] }>>(
|
||||
'/admin/orders/refund-pending'
|
||||
)
|
||||
return data.data.items ?? []
|
||||
}
|
||||
|
||||
export async function approveAdminRefund(id: number | string) {
|
||||
await adminClient.post(`/admin/orders/${id}/refund/approve`)
|
||||
}
|
||||
|
||||
export async function rejectAdminRefund(id: number | string, action: 'restore' | 'close') {
|
||||
await adminClient.post(`/admin/orders/${id}/refund/reject`, { action })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user