295 lines
9.5 KiB
TypeScript
295 lines
9.5 KiB
TypeScript
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>
|
|
)
|
|
}
|