diff --git a/docs/Phase1-验收报告.md b/docs/Phase1-验收报告.md index a9e2228..5011ddb 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` / `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`;其余页面仍需核对。 +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`,提现 `withdrawal:list` / `withdrawal:detail` / `withdrawal:review` / `withdrawal:pay`;其余页面仍需核对。 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..5372ea2 --- /dev/null +++ b/docs/Phase2-提现审核验收.md @@ -0,0 +1,67 @@ +# Phase 2 验收报告(后台提现审核) + +> 日期:2026-10-30 +> 项目:解忧商行(hfb_jysh) +> 范围:`/console-jy8k3m/withdrawals` +> 结论:**本切片通过,提现列表、详情、审核与打款确认已接真实接口** + +--- + +## 1. 接口与权限 + +| 能力 | 接口 | 权限码 | +| --- | --- | --- | +| 提现列表 | `GET /api/admin/withdrawals` | `withdrawal:list` | +| 提现详情 | `GET /api/admin/withdrawals/:id` | `withdrawal:detail` | +| 审核通过/驳回 | `POST /api/admin/withdrawals/:id/review` | `withdrawal:review` | +| 确认打款 | `POST /api/admin/withdrawals/:id/confirm-payment` | `withdrawal:pay` | + +前端菜单权限已从推断值 `withdrawal:view` 修正为后端实际的 `withdrawal:list`。 + +## 2. 提现列表 + +`frontend/src/features/admin/AdminWithdrawalsPage.tsx` + +- 筛选:提现状态 +- 列:提现单(单号/用户/分站)、金额(申请/手续费/到账)、收款账户、状态、申请时间、审核与打款、处理 +- 分页 10 / 20 / 50,翻页保留旧数据 +- 点击单号、行或「处理」打开详情抽屉 +- 无 `withdrawal:list` / `withdrawal:detail` 时隐藏处理入口 + +## 3. 提现详情与操作 + +`frontend/src/features/admin/AdminWithdrawalDetailDrawer.tsx` + +- 分段:申请概览、提现金额、收款账户、审核与打款、收款凭证 +- 收款账号在后台显示完整值,凭证图片支持预览 +- 待审核 + `withdrawal:review`: + - 通过审核(备注可选),进入待打款 + - 驳回(原因必填),资金退回用户可用余额 +- 待打款 + `withdrawal:pay`: + - 确认已打款(备注可选、打款凭证地址可选) +- 操作成功后刷新详情与列表 + +## 4. 验证结果 + +```text +npx tsc --noEmit 通过 +npm run build 通过(入口 496.12 kB,gzip 161.16 kB) +GET /api/admin/withdrawals?page=1&page_size=5 ok,{"items":[],"total":0} +GET /console-jy8k3m/withdrawals 200 +``` + +开发库当前没有提现申请,审核与打款确认未做真实写入验证,避免污染资金数据;请求体与后端 handler 已逐项核对。 + +## 5. 已知限制 + +1. 打款凭证地址需手工填写,暂未接后台文件上传。 +2. 未做批量审核、导出与提现对账单。 +3. 提现状态流转依赖后端,前端未做轮询。 +4. 财务看板仍未迁移。 + +## 6. 下一步建议 + +1. 后台财务看板与资金流水。 +2. 后台申诉仲裁。 +3. 商品审核调价与转移号主。 +4. 移动端下单、支付与交接/归还/结账。 diff --git a/docs/项目计划.md b/docs/项目计划.md index 643652b..e75b30e 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 162f1fb..58d7006 100644 --- a/frontend/src/app/AppRouter.tsx +++ b/frontend/src/app/AppRouter.tsx @@ -83,6 +83,11 @@ const AdminRefundReviewPage = lazy(() => default: module.AdminRefundReviewPage, })) ) +const AdminWithdrawalsPage = lazy(() => + import('@/features/admin/AdminWithdrawalsPage').then(module => ({ + default: module.AdminWithdrawalsPage, + })) +) const AdminLayout = lazy(() => import('@/layouts/AdminLayout').then(module => ({ default: module.AdminLayout })) ) @@ -93,7 +98,6 @@ const AdminSectionPlaceholder = lazy(() => const adminSections: Array<{ path: string; title: string; description: string }> = [ { path: 'disputes', title: '申诉仲裁', description: '处理订单争议、证据与仲裁结果。' }, { path: 'finance', title: '财务看板', description: '查看收入、退款、结算与资金趋势。' }, - { path: 'withdrawals', title: '提现审核', description: '审核提现申请与打款确认。' }, { path: 'distribution/sites', title: '分销站点', description: '管理分站、规则、结算与站点钱包。' }, { path: 'announcements', title: '公告管理', description: '维护主站与分站公告。' }, { path: 'system-configs', title: '系统配置', description: '维护首页、发布、订单与支付配置。' }, @@ -134,6 +138,7 @@ export function AppRouter() { } /> } /> } /> + } /> {adminSections.map(section => ( = { + approve: { + title: '通过提现审核', + okText: '通过审核', + hint: '通过后进入待打款状态,请确认收款账户与金额无误。', + }, + reject: { + title: '驳回提现申请', + okText: '确认驳回', + hint: '驳回后资金会退回用户可用余额,请填写驳回原因。', + }, + pay: { + title: '确认已打款', + okText: '确认打款', + hint: '确认后提现单完结;建议填写打款备注或凭证地址,便于对账。', + }, +} + +function buildSections(item: AdminWithdrawal): DetailSection[] { + const sections: DetailSection[] = [ + { + key: 'overview', + title: '申请概览', + fields: [ + { + key: 'withdraw_no', + label: '提现单号', + value: {item.withdraw_no}, + }, + { + key: 'status', + label: '提现状态', + value: , + }, + { key: 'user', label: '用户', value: `${item.user_nickname || '未设置昵称'} · ${item.user_phone}` }, + { key: 'user_id', label: '用户 ID', value: `UID ${item.user_id}` }, + { + key: 'site', + label: '来源分站', + value: item.distribution_site_name + ? `${item.distribution_site_name}${item.distribution_site_code ? ` · ${item.distribution_site_code}` : ''}` + : '主站', + }, + { key: 'created', label: '申请时间', value: formatDateTime(item.created_at) }, + { key: 'updated', label: '更新时间', value: formatDateTime(item.updated_at) }, + ], + }, + { + key: 'amount', + title: '提现金额', + fields: [ + { key: 'amount', label: '申请金额', value: formatMoney(item.amount_cent) }, + { key: 'fee', label: '手续费', value: formatMoney(item.fee_cent) }, + { key: 'actual', label: '实际到账', value: formatMoney(item.actual_amount_cent) }, + { + key: 'account_id', + label: '收款账户 ID', + value: item.payment_account_id ? `#${item.payment_account_id}` : emptyText, + }, + ], + }, + { + key: 'account', + title: '收款账户', + fields: [ + { key: 'type', label: '账户类型', value: formatText(item.account_type) }, + { key: 'name', label: '收款人', value: formatText(item.account_name) }, + { + key: 'no', + label: '收款账号', + value: {formatText(item.account_no)}, + span: 2, + }, + { key: 'bank', label: '开户银行', value: formatText(item.bank_name) }, + { key: 'branch', label: '开户支行', value: formatText(item.bank_branch) }, + ], + }, + { + key: 'audit', + title: '审核与打款', + fields: [ + { key: 'reviewed_by', label: '审核人', value: formatText(item.reviewed_by_name) }, + { key: 'reviewed_at', label: '审核时间', value: formatDateTime(item.reviewed_at) }, + { key: 'review_remark', label: '审核备注', value: formatText(item.review_remark), span: 2 }, + { key: 'paid_by', label: '打款人', value: formatText(item.paid_by_name) }, + { key: 'paid_at', label: '打款时间', value: formatDateTime(item.paid_at) }, + { key: 'payment_remark', label: '打款备注', value: formatText(item.payment_remark), span: 2 }, + { + key: 'proof', + label: '打款凭证', + span: 2, + value: item.payment_proof_url ? ( + + 查看打款凭证 + + ) : ( + emptyText + ), + }, + ], + }, + ] + + if (item.certificate_urls?.length > 0) { + sections.push({ + key: 'certificates', + title: '收款凭证', + fields: [ + { + key: 'images', + label: '凭证图片', + span: 2, + value: ( + + + {item.certificate_urls.map((url, index) => ( + {`收款凭证 + ))} + + + ), + }, + ], + }) + } + + return sections +} + +interface AdminWithdrawalDetailDrawerProps { + withdrawalId: number | null + open: boolean + onClose: () => void + onChanged?: () => void +} + +export function AdminWithdrawalDetailDrawer({ + withdrawalId, + open, + onClose, + onChanged, +}: AdminWithdrawalDetailDrawerProps) { + const { message } = App.useApp() + const permissions = useAdminSessionStore(state => state.admin?.permissions) + const can = (code: string) => Boolean(permissions?.includes('*') || permissions?.includes(code)) + const [action, setAction] = useState(null) + const [remark, setRemark] = useState('') + const [proofURL, setProofURL] = useState('') + const [submitting, setSubmitting] = useState(false) + + const query = useQuery({ + queryKey: ['admin-withdrawal', withdrawalId], + queryFn: () => fetchAdminWithdrawal(withdrawalId as number), + enabled: open && Boolean(withdrawalId), + }) + const item = query.data + + const openAction = (next: WithdrawalAction) => { + setRemark('') + setProofURL('') + setAction(next) + } + + const closeAction = () => { + if (submitting) return + setAction(null) + } + + const submit = async () => { + if (!item || !action) return + const text = remark.trim() + if (action === 'reject' && !text) { + message.warning('请填写驳回原因') + return + } + setSubmitting(true) + try { + if (action === 'approve') { + await reviewAdminWithdrawal(item.id, true, text) + message.success('提现已通过审核') + } else if (action === 'reject') { + await reviewAdminWithdrawal(item.id, false, text) + message.success('提现已驳回') + } else { + await confirmAdminWithdrawalPayment(item.id, { + remark: text || undefined, + payment_proof_url: proofURL.trim() || undefined, + }) + message.success('已确认打款') + } + setAction(null) + await query.refetch() + onChanged?.() + } catch (error) { + const text = + error && typeof error === 'object' && 'response' in error + ? ((error as { response?: { data?: { message?: string } } }).response?.data?.message ?? + '操作失败') + : error instanceof Error + ? error.message + : '操作失败' + message.error(text) + } finally { + setSubmitting(false) + } + } + + const copy = action ? actionCopy[action] : null + const canReview = item?.status === 'pending' && can('withdrawal:review') + const canPay = item?.status === 'processing' && can('withdrawal:pay') + + return ( + <> + void query.refetch()} + title={item ? item.withdraw_no : '提现详情'} + subtitle={ + item ? ( + + {item.user_nickname || item.user_phone} · {formatMoney(item.actual_amount_cent)} 到账 + + ) : null + } + tags={ + item ? ( + + + {item.distribution_site_name ? {item.distribution_site_name} : null} + + ) : null + } + sections={item ? buildSections(item) : []} + footer={ + item ? ( + + {canReview ? ( + <> + + + + ) : null} + {canPay ? ( + + ) : null} + + + ) : ( + + ) + } + /> + + void submit()} + onCancel={closeAction} + > + {copy?.hint} + {item ? ( + + 提现单 {item.withdraw_no},申请 {formatMoney(item.amount_cent)},到账{' '} + {formatMoney(item.actual_amount_cent)},收款人 {item.account_name}( + {item.account_no})。 + + ) : null} + setRemark(event.target.value)} + /> + {action === 'pay' ? ( + setProofURL(event.target.value)} + /> + ) : null} + + + ) +} diff --git a/frontend/src/features/admin/AdminWithdrawalsPage.tsx b/frontend/src/features/admin/AdminWithdrawalsPage.tsx new file mode 100644 index 0000000..3a1f41a --- /dev/null +++ b/frontend/src/features/admin/AdminWithdrawalsPage.tsx @@ -0,0 +1,249 @@ +import { useMemo, useState } from 'react' +import { keepPreviousData, useQuery } from '@tanstack/react-query' +import { Alert, Button, Select, Space, Tag, Typography, type TableColumnsType } from 'antd' +import { EyeOutlined, ReloadOutlined } from '@ant-design/icons' + +import { fetchAdminWithdrawals } from '@/features/admin/api' +import { AdminWithdrawalDetailDrawer } from '@/features/admin/AdminWithdrawalDetailDrawer' +import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader' +import { DataTable } from '@/features/admin/components/DataTable' +import { FilterBar, FilterField } from '@/features/admin/components/FilterBar' +import { StatusTag } from '@/features/admin/components/StatusTag' +import { readStatusMeta, withdrawalStatusMap, withdrawalStatusOptions } from '@/features/admin/labels' +import type { AdminWithdrawal, AdminWithdrawalQuery } from '@/features/admin/types' +import { useAdminSessionStore } from '@/stores/adminSession' +import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format' + +const emptyFilters: AdminWithdrawalQuery = { + status: '', +} + +function buildColumns( + onOpen: (row: AdminWithdrawal) => void, + canView: boolean +): TableColumnsType { + return [ + { + title: '提现单', + dataIndex: 'withdraw_no', + width: 230, + fixed: 'left', + render: (_, row) => ( +
+ {canView ? ( + + ) : ( + {row.withdraw_no} + )} + {row.user_nickname || row.user_phone || `UID ${row.user_id}`} + + {formatText(row.user_phone)} + {row.distribution_site_name ? ` · ${row.distribution_site_name}` : ''} + +
+ ), + }, + { + title: '提现金额', + key: 'amount', + width: 150, + align: 'right', + render: (_, row) => ( +
+ {formatMoney(row.amount_cent)} + 手续费 {formatMoney(row.fee_cent)} + 到账 {formatMoney(row.actual_amount_cent)} +
+ ), + }, + { + title: '收款账户', + key: 'account', + width: 220, + render: (_, row) => ( +
+ + {formatText(row.account_type)} · {formatText(row.account_name)} + + {formatText(row.account_no)} + + {formatText(row.bank_name)} + {row.bank_branch ? ` · ${row.bank_branch}` : ''} + +
+ ), + }, + { + title: '状态', + dataIndex: 'status', + width: 110, + render: value => , + }, + { + title: '申请时间', + dataIndex: 'created_at', + width: 170, + render: value => formatDateTime(value), + }, + { + title: '审核 / 打款', + key: 'audit', + width: 190, + render: (_, row) => ( +
+ 审核 {row.reviewed_by_name || '—'} + 打款 {row.paid_by_name || '—'} + {formatDateTime(row.paid_at || row.reviewed_at)} +
+ ), + }, + { + title: '操作', + key: 'actions', + width: 96, + fixed: 'right', + render: (_, row) => + canView ? ( + + ) : ( + 无权限 + ), + }, + ] +} + +export function AdminWithdrawalsPage() { + const [draft, setDraft] = useState(emptyFilters) + const [applied, setApplied] = useState(emptyFilters) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + const [detailId, setDetailId] = useState(null) + const [detailOpen, setDetailOpen] = useState(false) + const permissions = useAdminSessionStore(state => state.admin?.permissions) + const canView = Boolean( + permissions?.includes('*') || + permissions?.includes('withdrawal:list') || + permissions?.includes('withdrawal:detail') + ) + + const { data, isPending, isFetching, isError, error, refetch } = useQuery({ + queryKey: ['admin-withdrawals', applied, page, pageSize], + queryFn: () => fetchAdminWithdrawals({ ...applied, page, page_size: pageSize }), + placeholderData: keepPreviousData, + }) + + const columns = useMemo(() => buildColumns(row => { + setDetailId(row.id) + setDetailOpen(true) + }, canView), [canView]) + + const patchFilter = (patch: Partial) => { + setDraft(current => ({ ...current, ...patch })) + } + + const applyFilters = () => { + setApplied({ ...draft }) + setPage(1) + } + + const resetFilters = () => { + setDraft(emptyFilters) + setApplied(emptyFilters) + setPage(1) + } + + const hasActiveFilters = Boolean(draft.status) + const errorText = error instanceof Error ? error.message : '请稍后重试或重新登录后台。' + + return ( +
+ + 共 {data?.total ?? 0} 笔 + {isFetching && !isPending ? 正在更新 : null} + + } + extra={ + + } + /> + + + +