diff --git a/docs/Phase1-验收报告.md b/docs/Phase1-验收报告.md index 4499d50..a9e2228 100644 --- a/docs/Phase1-验收报告.md +++ b/docs/Phase1-验收报告.md @@ -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. 移动端、订单、分销轻改与首屏优化均已提交;商品管理与审核在本批次提交。 diff --git a/docs/Phase2-退款审核验收.md b/docs/Phase2-退款审核验收.md new file mode 100644 index 0000000..f35ac40 --- /dev/null +++ b/docs/Phase2-退款审核验收.md @@ -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. 移动端下单、支付与交接/归还/结账。 diff --git a/docs/项目计划.md b/docs/项目计划.md index 8ae34a2..643652b 100644 --- a/docs/项目计划.md +++ b/docs/项目计划.md @@ -395,7 +395,7 @@ hfb_jysh/ ### Phase 4:管理后台核心(4–5 周) -> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单列表/详情、商品列表/详情与审核、用户列表/详情与人工操作;退款、提现、申诉与系统配置待迁移。 +> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单列表/详情、退款审核、商品列表/详情与审核、用户列表/详情与人工操作;提现、申诉与系统配置待迁移。 - 后台登录、工作台、订单列表、订单详情。 - 商品列表、商品详情、审核。 diff --git a/frontend/src/app/AppRouter.tsx b/frontend/src/app/AppRouter.tsx index 2ced5b6..162f1fb 100644 --- a/frontend/src/app/AppRouter.tsx +++ b/frontend/src/app/AppRouter.tsx @@ -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() { }> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/features/admin/AdminRefundReviewPage.tsx b/frontend/src/features/admin/AdminRefundReviewPage.tsx new file mode 100644 index 0000000..4fd9ec5 --- /dev/null +++ b/frontend/src/features/admin/AdminRefundReviewPage.tsx @@ -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 { + return [ + { + title: '订单', + dataIndex: 'order_no', + width: 220, + fixed: 'left', + render: (_, row) => ( +
+ {canView ? ( + + ) : ( + {row.order_no} + )} + {row.title} + 商品 {formatText(row.listing_no)} +
+ ), + }, + { + title: '交易双方', + key: 'parties', + width: 180, + render: (_, row) => ( +
+ 租客 {formatText(row.renter_phone)} + 号主 {formatText(row.owner_phone)} +
+ ), + }, + { + title: '退款金额', + dataIndex: 'refund_amount_cent', + width: 140, + align: 'right', + render: (_, row) => ( +
+ {formatMoney(row.refund_amount_cent)} + 订单 {formatMoney(row.display_amount_cent)} + 押金 {formatMoney(row.deposit_amount_cent)} +
+ ), + }, + { + title: '订单状态', + key: 'status', + width: 170, + render: (_, row) => ( +
+ + +
+ ), + }, + { + title: '申请时间', + dataIndex: 'updated_at', + width: 170, + render: value => formatDateTime(value), + }, + { + title: '操作', + key: 'actions', + width: 190, + fixed: 'right', + render: (_, row) => ( + + {canView ? ( + + ) : null} + {canClose ? ( + <> + + + + ) : null} + + ), + }, + ] +} + +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(null) + const [detailOpen, setDetailOpen] = useState(false) + const [rejectOrder, setRejectOrder] = useState(null) + const [rejectMode, setRejectMode] = useState('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 ( +
+ + 待审核 {items.length} 单 + {query.isFetching ? 正在更新 : null} + + } + extra={ + + } + /> + + {!canClose ? ( + + ) : null} + + {query.isError ? ( + void query.refetch()}> + 重试 + + } + /> + ) : null} + + + title={待审核退款} + extra={ + + {query.isPending ? '加载中' : `共 ${items.length} 单`} + + } + rowKey="id" + columns={columns} + dataSource={items} + loading={query.isFetching} + emptyDescription="当前没有待审核退款" + onRowClick={canView ? row => openDetail(row) : undefined} + /> + + {canView ? ( + setDetailOpen(false)} + /> + ) : null} + + void submitReject()} + onCancel={() => { + if (!submitting) setRejectOrder(null) + }} + > + + 订单 {rejectOrder?.order_no},退款金额 {formatMoney(rejectOrder?.refund_amount_cent)}。 + 请选择驳回后的订单处理方式。 + + setRejectMode(event.target.value as RejectMode)} + style={{ display: 'flex', flexDirection: 'column', gap: 12 }} + > + + 恢复订单至待交接 + + 仅驳回退款,订单继续流转,商品保持占用。 + + + + 驳回退款并关闭订单 + + 关闭订单并归档商品,后续不可再操作。 + + + + +
+ ) +} diff --git a/frontend/src/features/admin/api.ts b/frontend/src/features/admin/api.ts index 07efe08..4454daa 100644 --- a/frontend/src/features/admin/api.ts +++ b/frontend/src/features/admin/api.ts @@ -217,3 +217,18 @@ export async function adjustAdminUserWallet( ) return data.data } + +export async function fetchPendingRefundOrders() { + const { data } = await adminClient.get>( + '/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 }) +}