feat(admin): 新增后台退款审核队列

This commit is contained in:
yml2213
2026-10-30 13:10:00 +08:00
parent 3297fd69ff
commit a754a71f71
6 changed files with 377 additions and 4 deletions
+2 -2
View File
@@ -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. 移动端、订单、分销轻改与首屏优化均已提交;商品管理与审核在本批次提交。
+59
View File
@@ -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
View File
@@ -395,7 +395,7 @@ hfb_jysh/
### Phase 4:管理后台核心(4–5 周)
> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单列表/详情、商品列表/详情与审核、用户列表/详情与人工操作;退款、提现、申诉与系统配置待迁移。
> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单列表/详情、退款审核、商品列表/详情与审核、用户列表/详情与人工操作;提现、申诉与系统配置待迁移。
- 后台登录、工作台、订单列表、订单详情。
- 商品列表、商品详情、审核。
+6 -1
View File
@@ -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>
)
}
+15
View File
@@ -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 })
}