feat(admin): 新增后台退款审核队列
This commit is contained in:
+2
-2
@@ -194,9 +194,9 @@ dist/assets/AdminLayout-*.js 36.87 kB │ gzip 13.02 kB
|
||||
## 5. 已知限制
|
||||
|
||||
1. 移动端订单列表/详情与消息中心已接入真实接口;下单、支付、交接、归还、结账等操作仍待迁移。
|
||||
2. 后台已完成仪表盘、订单列表/详情、商品列表/详情与审核、用户列表/详情与人工操作,其余页面仍为统一占位。
|
||||
2. 后台已完成仪表盘、订单列表/详情、退款审核、商品列表/详情与审核、用户列表/详情与人工操作,其余页面仍为统一占位。
|
||||
3. 分销后台已完成主题、Logo、登录页与 Shell 轻改,页面结构未重写。
|
||||
4. 后台菜单权限码按后端逐个核对:订单 `order:list` / `order:view`,商品 `listing:view` / `listing:approve` / `listing:reject` / `listing:offline`,用户 `user:view` / `user:freeze` / `user:unfreeze` / `user:deposit_free` / `user:growth_points` / `user:manual_realname` / `user:revoke_realname` / `user:wallet_adjust`;其余页面仍需核对。
|
||||
4. 后台菜单权限码按后端逐个核对:订单 `order:list` / `order:view` / `order:close`,商品 `listing:view` / `listing:approve` / `listing:reject` / `listing:offline`,用户 `user:view` / `user:freeze` / `user:unfreeze` / `user:deposit_free` / `user:growth_points` / `user:manual_realname` / `user:revoke_realname` / `user:wallet_adjust`;其余页面仍需核对。
|
||||
5. SVG 中文尚未转曲。
|
||||
6. 入口包已降至 495 kB(gzip 161 kB);AntD 共享基础设施仍在入口,后续可拆独立入口。
|
||||
7. 移动端、订单、分销轻改与首屏优化均已提交;商品管理与审核在本批次提交。
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Phase 2 验收报告(后台退款审核)
|
||||
|
||||
> 日期:2026-10-30
|
||||
> 项目:解忧商行(hfb_jysh)
|
||||
> 范围:`/console-jy8k3m/orders/refund-review`
|
||||
> 结论:**本切片通过,退款队列与通过/驳回动作已接真实接口**
|
||||
|
||||
---
|
||||
|
||||
## 1. 接口与权限
|
||||
|
||||
| 能力 | 接口 | 权限码 |
|
||||
| --- | --- | --- |
|
||||
| 待审退款队列 | `GET /api/admin/orders/refund-pending` | `order:close` |
|
||||
| 通过退款 | `POST /api/admin/orders/:id/refund/approve` | `order:close` |
|
||||
| 驳回退款 | `POST /api/admin/orders/:id/refund/reject`(`action=restore|close`) | `order:close` |
|
||||
| 订单详情 | `GET /api/admin/orders/:id` | `order:view` |
|
||||
|
||||
后端 `ListPendingRefund` 直接返回完整订单 DTO(最多 200 条,按更新时间升序),前端不再二次请求详情即可展示金额与状态。
|
||||
|
||||
## 2. 页面行为
|
||||
|
||||
`frontend/src/features/admin/AdminRefundReviewPage.tsx`
|
||||
|
||||
- 头部:待审核数量、刷新按钮
|
||||
- 表格:订单(编号/标题/商品编号)、交易双方、退款金额与订单金额/押金、订单与交接状态、申请时间、操作
|
||||
- 通过退款:
|
||||
- 二次确认,明确展示原路退款金额
|
||||
- 成功后刷新退款队列,并失效订单列表与仪表盘缓存
|
||||
- 驳回退款:
|
||||
- 弹窗内选择「恢复订单至待交接」或「驳回并关闭订单」
|
||||
- 两种模式分别对应后端 `restore` / `close`
|
||||
- 详情:复用订单详情抽屉,展示结账单、金额、交接与结算
|
||||
- 无 `order:close` 时只读并提示权限;无 `order:view` 时隐藏详情入口
|
||||
|
||||
## 3. 验证结果
|
||||
|
||||
```text
|
||||
npx tsc --noEmit 通过
|
||||
npm run build 通过(入口 495.83 kB,gzip 161.10 kB)
|
||||
GET /api/admin/orders/refund-pending ok,{"items":[]}
|
||||
GET /console-jy8k3m/orders/refund-review 200
|
||||
```
|
||||
|
||||
开发库当前没有待审核退款,通过/驳回未做真实写入验证,避免污染订单与资金数据;请求体与后端 handler 已逐项核对。
|
||||
|
||||
## 4. 已知限制
|
||||
|
||||
1. 后端驳回只接收 `action`,没有驳回原因字段;页面未收集原因。
|
||||
2. 未做批量审核、退款金额调整或部分退款。
|
||||
3. 退款队列最多返回 200 条,暂未分页。
|
||||
4. 退款渠道状态(退款中/失败)仍以订单详情字段为准,未单独接轮询。
|
||||
|
||||
## 5. 下一步建议
|
||||
|
||||
1. 后台提现审核与财务看板。
|
||||
2. 后台申诉仲裁。
|
||||
3. 商品审核调价与转移号主。
|
||||
4. 移动端下单、支付与交接/归还/结账。
|
||||
+1
-1
@@ -395,7 +395,7 @@ hfb_jysh/
|
||||
|
||||
### Phase 4:管理后台核心(4–5 周)
|
||||
|
||||
> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单列表/详情、商品列表/详情与审核、用户列表/详情与人工操作;退款、提现、申诉与系统配置待迁移。
|
||||
> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单列表/详情、退款审核、商品列表/详情与审核、用户列表/详情与人工操作;提现、申诉与系统配置待迁移。
|
||||
|
||||
- 后台登录、工作台、订单列表、订单详情。
|
||||
- 商品列表、商品详情、审核。
|
||||
|
||||
@@ -78,6 +78,11 @@ const AdminUsersPage = lazy(() =>
|
||||
default: module.AdminUsersPage,
|
||||
}))
|
||||
)
|
||||
const AdminRefundReviewPage = lazy(() =>
|
||||
import('@/features/admin/AdminRefundReviewPage').then(module => ({
|
||||
default: module.AdminRefundReviewPage,
|
||||
}))
|
||||
)
|
||||
const AdminLayout = lazy(() =>
|
||||
import('@/layouts/AdminLayout').then(module => ({ default: module.AdminLayout }))
|
||||
)
|
||||
@@ -86,7 +91,6 @@ const AdminSectionPlaceholder = lazy(() =>
|
||||
)
|
||||
|
||||
const adminSections: Array<{ path: string; title: string; description: string }> = [
|
||||
{ path: 'orders/refund-review', title: '退款审核', description: '审核退款申请并核对资金影响。' },
|
||||
{ path: 'disputes', title: '申诉仲裁', description: '处理订单争议、证据与仲裁结果。' },
|
||||
{ path: 'finance', title: '财务看板', description: '查看收入、退款、结算与资金趋势。' },
|
||||
{ path: 'withdrawals', title: '提现审核', description: '审核提现申请与打款确认。' },
|
||||
@@ -126,6 +130,7 @@ export function AppRouter() {
|
||||
<Route element={<AdminLayout />}>
|
||||
<Route index element={<AdminDashboardPage />} />
|
||||
<Route path="orders" element={<AdminOrdersPage />} />
|
||||
<Route path="orders/refund-review" element={<AdminRefundReviewPage />} />
|
||||
<Route path="listings" element={<AdminListingsPage />} />
|
||||
<Route path="listings/review" element={<AdminListingsPage mode="review" />} />
|
||||
<Route path="users" element={<AdminUsersPage />} />
|
||||
|
||||
@@ -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