diff --git a/docs/Phase2-资金闭环验收.md b/docs/Phase2-资金闭环验收.md new file mode 100644 index 0000000..9676bf1 --- /dev/null +++ b/docs/Phase2-资金闭环验收.md @@ -0,0 +1,103 @@ +# Phase 2 验收报告(B1 资金闭环) + +> 日期:2026-10-30 +> 项目:解忧商行(hfb_jysh) +> 范围:C 端钱包、收款账户、提现、移动端提现、卖家收益/待办/线下提号、后台支付流水/资金明细/钱包流水 +> 结论:**页面与路由全部落地,tsc/build 通过,只读接口回归 200;写操作未在验收中执行** + +--- + +## 1. C 端钱包 + +- 页面:`frontend/src/features/wallet/WalletPage.tsx` +- 路由:`/wallet` +- 接口:`GET /api/wallet/balance`、`GET /api/wallet/ledger` +- 能力:可用/冻结余额、账户状态、资金流水(业务、方向、金额、余额类型、变化后余额、备注、时间)与分页。 +- 金额全部按分入参,展示统一 `formatMoney(cents)`;入账/出账用颜色与符号区分。 + +## 2. 收款账户 + +- 页面:`frontend/src/features/wallet/PaymentAccountsPage.tsx` +- 路由:`/wallet/payment-accounts` +- 接口:`GET/POST /api/payment-accounts`、`PUT/DELETE /api/payment-accounts/:id`、`POST /api/payment-accounts/:id/set-default` +- 能力: + - 卡片式列表,展示收款方式、脱敏账号、开户行、默认标记、凭证数量。 + - 新增:收款方式(支付宝/微信/银行卡)、收款人、账号;银行卡额外填开户行与支行。 + - 编辑仅允许修改支行、凭证与默认标记(后端 `UpdatePaymentAccountRequest` 不含账号字段)。 + - 收款二维码凭证通过 `POST /api/files/upload`(scene `payment-cert`)上传,最多 5 张。 + +## 3. 提现申请 + +- 页面:`frontend/src/features/wallet/WithdrawalPage.tsx` +- 路由:`/wallet/withdrawal` +- 接口:`POST /api/withdrawals`、`GET /api/withdrawals`、`POST /api/withdrawals/:id/cancel` +- 能力: + - 默认账户自动选中;金额按元录入、`Math.round(yuan * 100)` 转分提交。 + - 校验金额 > 0 且不超过可用余额;当前手续费 0%,页面明确「到账金额以审核结果为准」。 + - 提现记录展示金额、手续费/到账、收款方式、状态、备注、申请时间;仅 `pending` 可取消。 + +## 4. 移动端提现 + +- 页面:`frontend/src/features/mobile/MobileWithdrawalPage.tsx` +- 路由:`/m/wallet/withdrawal` +- 组件:antd-mobile(NavBar / Card / Selector / Form / List / Dialog / Toast) +- 能力:余额展示、账户选择、金额录入、提交、记录列表与取消;无收款账户时引导到电脑端添加。 + +## 5. 卖家三页 + +| 页面 | 路由 | 接口 | 说明 | +|---|---|---|---| +| 号主待办 | `/seller/handoffs` | `GET /api/orders/handoffs` | 待交接/待确认结账/异常三项统计 + 订单列表(复用 `UserOrderListItem`) | +| 收益流水 | `/seller/earnings` | `GET /api/wallet/balance`、`/api/wallet/ledger` | 可用/冻结汇总 + 流水 + 提现入口 | +| 线下提号记录 | `/seller/pickups` | `GET /api/seller/pickups` | 提号编号、账号、操作人/平台、订单金额、应结算、状态与结算状态 | + +文件:`frontend/src/features/seller/SellerHandoffsPage.tsx`、`SellerEarningsPage.tsx`、`SellerPickupsPage.tsx`,接口与类型扩展在 `frontend/src/features/seller/api.ts`。 + +## 6. 后台三页 + +| 页面 | 路由 | 接口 | 权限 | +|---|---|---|---| +| 支付流水 | `/console-jy8k3m/payments` | `GET /api/admin/payments` | `wallet:view` | +| 资金明细 | `/console-jy8k3m/finance/details` | `GET /api/admin/finance/details` | `wallet:view` | +| 钱包流水 | `/console-jy8k3m/wallet-ledger` | `GET /api/admin/wallet/ledger` | `wallet:view` | + +- 支付流水支持订单号、业务类型、状态、渠道筛选,展示金额、支付时间与失败原因。 +- 资金明细支持订单号、日期口径(下单/结算/线下结算)、日期区间与结算状态筛选,展示实付/退款、号主结算、平台净额、结算时间。 +- 钱包流水支持用户 ID、订单 ID、业务类型筛选,展示方向、金额、变化后余额。 +- 三者均为只读页面,未提供任何写操作入口。 + +## 7. 导航接入 + +- `frontend/src/layouts/StorefrontLayout.tsx`:账号下拉新增「我的钱包 / 卖家收益 / 号主待办 / 线下提号」。 +- `frontend/src/layouts/AdminLayout.tsx`:财务组新增「资金明细、支付流水、钱包流水」,并补 `sectionMeta`。 +- 新增样式 `frontend/src/styles/wallet.css`(已接入 `styles/global.css`)。 + +## 8. 回归结果 + +```text +前端 http://localhost:5289 +后端 http://127.0.0.1:18089 +``` + +| 检查项 | 状态 | +|---|---| +| `/wallet`、`/wallet/payment-accounts`、`/wallet/withdrawal` | 200 | +| `/seller/handoffs`、`/seller/earnings`、`/seller/pickups` | 200 | +| `/m/wallet/withdrawal` | 200 | +| `/console-jy8k3m/payments`、`/finance/details`、`/wallet-ledger` | 200 | +| `GET /api/admin/payments` | 200 | +| `GET /api/admin/finance/details` | 200 | +| `GET /api/admin/wallet/ledger` | 200 | +| `GET /api/wallet/balance`(无 token) | 401(符合鉴权预期) | + +`npx tsc --noEmit` 通过;`npm run build` 通过,主入口 `497.95 kB` / gzip `156.88 kB`,无超大包告警。 + +## 9. 未覆盖 + +- 提现申请、取消、收款账户增删改、凭证上传均未做端到端写操作验证;开发库当前无真实用户余额与支付数据。 +- 移动端提现页未在真机/移动视口做交互回归。 +- 真实外部支付、短信与实名服务仍为空配置,不在本轮范围。 + +## 10. 下一步(B2) + +线下提号(`/pickup`、`/pickup/:id`,命名统一「线下提号」并修正 `labels.ts` 现有「捡漏」译法)、客服会话工作台(`/chats`)、主站二维码池(`/qrcode-pool`)、通知/推送/邀请管理。 diff --git a/docs/项目计划.md b/docs/项目计划.md index c9ce4e7..9753236 100644 --- a/docs/项目计划.md +++ b/docs/项目计划.md @@ -409,6 +409,9 @@ hfb_jysh/ ### Phase 5:后台长尾 + 分销后台轻改(2–2.5 周) - 财务看板、资金明细、运营支出。 + - 已落地:财务看板、资金明细 `/finance/details`、资金出款、运营开支、支付流水 `/payments`、钱包流水 `/wallet-ledger`。 +- 用户端资金闭环。 + - 已落地:`/wallet`(余额 + 流水)、`/wallet/payment-accounts`、`/wallet/withdrawal`、移动端 `/m/wallet/withdrawal`、卖家 `/seller/earnings`、`/seller/handoffs`、`/seller/pickups`。 - 申诉仲裁、手工订单、提号、客服群、快捷回复。 - 已落地:客服群组(`/chat-support-groups`,权限 `chat:manage`),角色权限矩阵(`/roles`,权限 `role:manage`),手工录单(`/manual-entries`,权限 `cs_manual_entry:*`),快捷回复(`/chat-quick-replies`,权限 `chat:view` / `chat:send`)。 - 待落地:无(手工录单与快捷回复已迁移完毕)。 diff --git a/frontend/src/app/AppRouter.tsx b/frontend/src/app/AppRouter.tsx index cea8381..91bed05 100644 --- a/frontend/src/app/AppRouter.tsx +++ b/frontend/src/app/AppRouter.tsx @@ -28,12 +28,38 @@ const SellerListingFormPage = lazy(() => default: module.SellerListingFormPage, })) ) +const SellerHandoffsPage = lazy(() => + import('@/features/seller/SellerHandoffsPage').then(module => ({ + default: module.SellerHandoffsPage, + })) +) +const SellerEarningsPage = lazy(() => + import('@/features/seller/SellerEarningsPage').then(module => ({ + default: module.SellerEarningsPage, + })) +) +const SellerPickupsPage = lazy(() => + import('@/features/seller/SellerPickupsPage').then(module => ({ + default: module.SellerPickupsPage, + })) +) const OrderListPage = lazy(() => import('@/features/orders/OrderListPage').then(module => ({ default: module.OrderListPage })) ) const OrderDetailPage = lazy(() => import('@/features/orders/OrderDetailPage').then(module => ({ default: module.OrderDetailPage })) ) +const WalletPage = lazy(() => + import('@/features/wallet/WalletPage').then(module => ({ default: module.WalletPage })) +) +const PaymentAccountsPage = lazy(() => + import('@/features/wallet/PaymentAccountsPage').then(module => ({ + default: module.PaymentAccountsPage, + })) +) +const WithdrawalPage = lazy(() => + import('@/features/wallet/WithdrawalPage').then(module => ({ default: module.WithdrawalPage })) +) const MessagesPage = lazy(() => import('@/features/messages/MessagesPage').then(module => ({ default: module.MessagesPage })) ) @@ -59,6 +85,11 @@ const MobileProfilePage = lazy(() => default: module.MobileProfilePage, })) ) +const MobileWithdrawalPage = lazy(() => + import('@/features/mobile/MobileWithdrawalPage').then(module => ({ + default: module.MobileWithdrawalPage, + })) +) const MobileOrdersPage = lazy(() => import('@/features/mobile/MobileOrdersPage').then(module => ({ default: module.MobileOrdersPage, @@ -200,6 +231,21 @@ const AdminManualEntriesPage = lazy(() => default: module.AdminManualEntriesPage, })) ) +const AdminPaymentsPage = lazy(() => + import('@/features/admin/AdminPaymentsPage').then(module => ({ + default: module.AdminPaymentsPage, + })) +) +const AdminFinanceDetailsPage = lazy(() => + import('@/features/admin/AdminFinanceDetailsPage').then(module => ({ + default: module.AdminFinanceDetailsPage, + })) +) +const AdminWalletLedgerPage = lazy(() => + import('@/features/admin/AdminWalletLedgerPage').then(module => ({ + default: module.AdminWalletLedgerPage, + })) +) const AdminQuickRepliesPage = lazy(() => import('@/features/admin/AdminQuickRepliesPage').then(module => ({ default: module.AdminQuickRepliesPage, @@ -293,12 +339,18 @@ export function AppRouter() { } /> } /> } /> + } /> + } /> + } /> } /> } /> } /> } /> } /> } /> + } /> + } /> + } /> } /> @@ -318,6 +370,7 @@ export function AppRouter() { } /> } /> } /> + } /> } /> @@ -342,6 +395,9 @@ export function AppRouter() { } /> } /> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/features/admin/AdminFinanceDetailsPage.tsx b/frontend/src/features/admin/AdminFinanceDetailsPage.tsx new file mode 100644 index 0000000..3039d78 --- /dev/null +++ b/frontend/src/features/admin/AdminFinanceDetailsPage.tsx @@ -0,0 +1,280 @@ +import { useMemo, useState } from 'react' +import { keepPreviousData, useQuery } from '@tanstack/react-query' +import { Alert, Button, DatePicker, Input, Select, Space, Tag, Typography, type TableColumnsType } from 'antd' +import { ReloadOutlined } from '@ant-design/icons' +import { type Dayjs } from 'dayjs' + +import { fetchFinanceDetails } from '@/features/admin/api' +import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader' +import { DataTable } from '@/features/admin/components/DataTable' +import { FilterBar, FilterField } from '@/features/admin/components/FilterBar' +import { orderStatusMap } from '@/features/admin/labels' +import type { FinanceDetail } from '@/features/admin/types' +import { useAdminSessionStore } from '@/stores/adminSession' +import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format' + +interface Filters { + order_no: string + date_type: 'created' | 'settled' | 'offline_settled' + range: [Dayjs, Dayjs] | null +} + +const emptyFilters: Filters = { order_no: '', date_type: 'settled', range: null } + +const settlementStatusOptions = [ + { value: 'pending', label: '待结算' }, + { value: 'settled', label: '已结算' }, + { value: 'frozen', label: '冻结中' }, +] + +function errorText(error: unknown) { + const response = (error as { response?: { data?: { message?: string } } })?.response + if (response?.data?.message) return response.data.message + return error instanceof Error ? error.message : '请求失败,请稍后重试' +} + +const columns: TableColumnsType = [ + { + title: '订单', + key: 'order', + width: 230, + fixed: 'left', + render: (_, row) => ( +
+ {row.order_no} + + {orderStatusMap[row.order_status]?.label ?? row.order_status} + + {row.finance_status === 'settled' ? '已结算' : '待结算'} + + +
+ ), + }, + { + title: '租客', + key: 'renter', + width: 160, + render: (_, row) => ( +
+ {formatText(row.renter_nickname)} + {formatText(row.renter_phone)} +
+ ), + }, + { + title: '号主', + key: 'owner', + width: 160, + render: (_, row) => ( +
+ {formatText(row.owner_nickname)} + {formatText(row.owner_phone)} +
+ ), + }, + { + title: '实付 / 退款', + key: 'amount', + width: 160, + align: 'right', + render: (_, row) => ( +
+ {formatMoney(row.paid_amount_cent)} + + 退款 {formatMoney(row.refunded_amount_cent)} + +
+ ), + }, + { + title: '号主结算', + key: 'owner_settle', + width: 150, + align: 'right', + render: (_, row) => ( +
+ {formatMoney(row.owner_effective_settlement_amount_cent)} + + 钱包 {formatMoney(row.owner_wallet_income_amount_cent)} + +
+ ), + }, + { + title: '平台净额', + dataIndex: 'platform_net_amount_cent', + width: 130, + align: 'right', + render: value => formatMoney(value), + }, + { + title: '结算模式', + key: 'mode', + width: 150, + render: (_, row) => ( +
+ {formatText(row.settlement_mode)} + {formatText(row.offline_settlement_status)} +
+ ), + }, + { + title: '创建时间', + dataIndex: 'created_at', + width: 170, + render: value => formatDateTime(value), + }, + { + title: '结算时间', + dataIndex: 'settled_at', + width: 170, + render: value => (value ? formatDateTime(value) : '—'), + }, +] + +export function AdminFinanceDetailsPage() { + const [filters, setFilters] = useState(emptyFilters) + const [applied, setApplied] = useState(emptyFilters) + const [settlementStatus, setSettlementStatus] = useState() + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + const permissions = useAdminSessionStore(state => state.admin?.permissions) + const canView = Boolean(permissions?.includes('*') || permissions?.includes('wallet:view')) + + const query = useMemo( + () => ({ + order_no: applied.order_no.trim() || undefined, + date_type: applied.date_type, + settlement_status: settlementStatus, + start_date: applied.range?.[0]?.format('YYYY-MM-DD'), + end_date: applied.range?.[1]?.format('YYYY-MM-DD'), + page, + page_size: pageSize, + }), + [applied, settlementStatus, page, pageSize] + ) + + const detailsQuery = useQuery({ + queryKey: ['admin-finance-details', query], + queryFn: () => fetchFinanceDetails(query), + enabled: canView, + placeholderData: keepPreviousData, + }) + + if (!canView) { + return ( +
+ + +
+ ) + } + + return ( +
+ + 共 {detailsQuery.data?.total ?? 0} 单 + + } + extra={ + + } + /> + + { + setPage(1) + setApplied(filters) + }} + onReset={() => { + setFilters(emptyFilters) + setApplied(emptyFilters) + setSettlementStatus(undefined) + setPage(1) + }} + > + + + setFilters(current => ({ ...current, order_no: event.target.value })) + } + /> + + + setSettlementStatus(value)} + /> + + + + {detailsQuery.isError ? ( + + ) : null} + + + columns={columns} + dataSource={detailsQuery.data?.items ?? []} + rowKey="order_id" + loading={detailsQuery.isFetching} + total={detailsQuery.data?.total ?? 0} + page={detailsQuery.data?.page ?? page} + pageSize={detailsQuery.data?.page_size ?? pageSize} + onPageChange={(nextPage, nextSize) => { + setPage(nextPage) + setPageSize(nextSize) + }} + emptyDescription="所选区间还没有资金明细" + /> +
+ ) +} diff --git a/frontend/src/features/admin/AdminPaymentsPage.tsx b/frontend/src/features/admin/AdminPaymentsPage.tsx new file mode 100644 index 0000000..c6ae0d8 --- /dev/null +++ b/frontend/src/features/admin/AdminPaymentsPage.tsx @@ -0,0 +1,269 @@ +import { useMemo, useState } from 'react' +import { keepPreviousData, useQuery } from '@tanstack/react-query' +import { Alert, Button, Input, Select, Space, Tag, Typography, type TableColumnsType } from 'antd' +import { ReloadOutlined } from '@ant-design/icons' + +import { fetchAdminPayments } from '@/features/admin/api' +import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader' +import { DataTable } from '@/features/admin/components/DataTable' +import { FilterBar, FilterField } from '@/features/admin/components/FilterBar' +import type { AdminPaymentQuery, AdminPaymentRow } from '@/features/admin/types' +import { useAdminSessionStore } from '@/stores/adminSession' +import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format' + +type Filters = Pick + +const emptyFilters: Filters = { order_no: '', biz_type: undefined, status: undefined, provider: undefined } + +const statusOptions = [ + { value: 'pending', label: '待支付' }, + { value: 'paid', label: '已支付' }, + { value: 'failed', label: '支付失败' }, + { value: 'closed', label: '已关闭' }, +] + +const bizTypeOptions = [ + { value: 'order_pay', label: '订单支付' }, + { value: 'main_recharge', label: '充值' }, +] + +const providerOptions = [ + { value: 'leshua', label: '拉卡拉' }, + { value: 'shuncheng', label: '顺诚' }, + { value: 'mock', label: '模拟支付' }, +] + +const statusMeta: Record = { + pending: { label: '待支付', color: 'gold' }, + paid: { label: '已支付', color: 'green' }, + failed: { label: '支付失败', color: 'red' }, + closed: { label: '已关闭', color: 'default' }, +} + +function statusTag(value?: string | null) { + if (!value) return 未知 + const meta = statusMeta[value] ?? { label: value, color: 'default' } + return ( + + {meta.label} + + ) +} + +function errorText(error: unknown) { + const response = (error as { response?: { data?: { message?: string } } })?.response + if (response?.data?.message) return response.data.message + return error instanceof Error ? error.message : '请求失败,请稍后重试' +} + +const columns: TableColumnsType = [ + { + title: '支付单号', + key: 'payment', + width: 240, + fixed: 'left', + render: (_, row) => ( +
+ {row.payment_no} + {formatText(row.order_no)} +
+ ), + }, + { + title: '用户', + key: 'user', + width: 170, + render: (_, row) => ( +
+ {formatText(row.user_phone)} + UID {row.user_id} +
+ ), + }, + { + title: '渠道 / 支付方式', + key: 'channel', + width: 170, + render: (_, row) => ( +
+ {formatText(row.provider)} + {formatText(row.pay_way)} +
+ ), + }, + { + title: '金额', + dataIndex: 'amount_cent', + width: 130, + align: 'right', + render: value => {formatMoney(value)}, + }, + { + title: '状态', + dataIndex: 'status', + width: 110, + render: value => statusTag(value), + }, + { + title: '支付时间', + dataIndex: 'paid_at', + width: 170, + render: value => (value ? formatDateTime(value) : '—'), + }, + { + title: '失败原因', + key: 'error', + render: (_, row) => + row.error_message ? ( + + {row.error_code ? `${row.error_code} · ` : ''} + {row.error_message} + + ) : ( + — + ), + }, + { + title: '创建时间', + dataIndex: 'created_at', + width: 170, + render: value => formatDateTime(value), + }, +] + +export function AdminPaymentsPage() { + const [filters, setFilters] = useState(emptyFilters) + const [applied, setApplied] = useState(emptyFilters) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + const permissions = useAdminSessionStore(state => state.admin?.permissions) + const canView = Boolean(permissions?.includes('*') || permissions?.includes('wallet:view')) + + const query = useMemo( + () => ({ + order_no: applied.order_no?.trim() || undefined, + biz_type: applied.biz_type, + status: applied.status, + provider: applied.provider, + page, + page_size: pageSize, + }), + [applied, page, pageSize] + ) + + const paymentsQuery = useQuery({ + queryKey: ['admin-payments', query], + queryFn: () => fetchAdminPayments(query), + enabled: canView, + placeholderData: keepPreviousData, + }) + + if (!canView) { + return ( +
+ + +
+ ) + } + + return ( +
+ + 共 {paymentsQuery.data?.total ?? 0} 条 + + } + extra={ + + } + /> + + { + setPage(1) + setApplied(filters) + }} + onReset={() => { + setFilters(emptyFilters) + setApplied(emptyFilters) + setPage(1) + }} + > + + + setFilters(current => ({ ...current, order_no: event.target.value })) + } + /> + + + setFilters(current => ({ ...current, status: value }))} + /> + + + setFilters(current => ({ ...current, biz_type: value }))} + /> + + + + {ledgerQuery.isError ? ( + + ) : null} + + + columns={columns} + dataSource={ledgerQuery.data?.items ?? []} + rowKey="id" + loading={ledgerQuery.isFetching} + total={ledgerQuery.data?.total ?? 0} + page={ledgerQuery.data?.page ?? page} + pageSize={ledgerQuery.data?.page_size ?? pageSize} + onPageChange={(nextPage, nextSize) => { + setPage(nextPage) + setPageSize(nextSize) + }} + emptyDescription="还没有钱包流水" + /> +
+ ) +} diff --git a/frontend/src/features/admin/api.ts b/frontend/src/features/admin/api.ts index 79c7047..f608ad2 100644 --- a/frontend/src/features/admin/api.ts +++ b/frontend/src/features/admin/api.ts @@ -98,6 +98,10 @@ import type { FinanceDetailPage, FinanceDisbursementPage, FinanceDisbursementQuery, + AdminPaymentPage, + AdminPaymentQuery, + AdminWalletLedgerPage, + AdminWalletLedgerQuery, ManualDisbursementPayload, OperatingExpensePage, OperatingExpensePayload, @@ -1365,3 +1369,30 @@ export async function voidOperatingExpense(id: number | string, reason: string) ) return data.data } + +export async function fetchAdminPayments(query: AdminPaymentQuery = {}) { + const { data } = await adminClient.get>>( + '/admin/payments', + { params: compactParams(query) } + ) + return { + items: data.data.items ?? [], + total: data.data.total ?? 0, + page: data.data.page ?? query.page ?? 1, + page_size: data.data.page_size ?? query.page_size ?? 20, + } +} + +export async function fetchAdminWalletLedger(query: AdminWalletLedgerQuery = {}) { + const { data } = await adminClient.get>>( + '/admin/wallet/ledger', + { params: compactParams(query) } + ) + return { + items: data.data.items ?? [], + total: data.data.total ?? 0, + page: data.data.page ?? query.page ?? 1, + page_size: data.data.page_size ?? query.page_size ?? 20, + } +} + diff --git a/frontend/src/features/admin/types.ts b/frontend/src/features/admin/types.ts index 4eeaa6d..d62ce7f 100644 --- a/frontend/src/features/admin/types.ts +++ b/frontend/src/features/admin/types.ts @@ -534,6 +534,81 @@ export interface FinanceDetailPage { page_size: number } +export interface AdminPaymentRow { + id: number + payment_no: string + order_id: number + order_no: string + user_id: number + payment_config_id: number + user_phone: string + provider: string + merchant_id: string + third_order_id: string + provider_order_id: string + pay_way: string + amount_cent: number + biz_type: string + status: string + error_code: string + error_message: string + paid_at?: string | null + notified_at?: string | null + created_at: string + updated_at: string +} + +export interface AdminPaymentPage { + items: AdminPaymentRow[] + total: number + page: number + page_size: number +} + +export interface AdminPaymentQuery { + user_id?: number + order_id?: number + order_no?: string + biz_type?: string + status?: string + provider?: string + page?: number + page_size?: number +} + +export interface AdminWalletLedgerRow { + id: number + ledger_no: string + user_id: number + user_phone: string + user_nickname: string + order_id?: number | null + order_no: string + direction: string + amount_cent: number + balance_after_cent: number + balance_type: string + biz_type: string + biz_no: string + remark: string + created_at: string +} + +export interface AdminWalletLedgerPage { + items: AdminWalletLedgerRow[] + total: number + page: number + page_size: number +} + +export interface AdminWalletLedgerQuery { + user_id?: number + order_id?: number + biz_type?: string + page?: number + page_size?: number +} + export interface AdminAnnouncementQuery { category?: string status?: string diff --git a/frontend/src/features/mobile/MobileWithdrawalPage.tsx b/frontend/src/features/mobile/MobileWithdrawalPage.tsx new file mode 100644 index 0000000..0a44813 --- /dev/null +++ b/frontend/src/features/mobile/MobileWithdrawalPage.tsx @@ -0,0 +1,231 @@ +import { useEffect, useMemo, useState } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { + Button, + Card, + Dialog, + ErrorBlock, + Form, + Input, + List, + NavBar, + Selector, + Skeleton, + Tag, + Toast, +} from 'antd-mobile' +import { useNavigate } from 'react-router-dom' + +import { + cancelWithdrawal, + createWithdrawal, + fetchPaymentAccounts, + fetchWalletBalance, + fetchWithdrawals, +} from '@/features/wallet/api' +import { paymentAccountTypeLabel, withdrawalStatusMeta } from '@/features/wallet/labels' +import { useSessionStore } from '@/stores/session' +import { formatDateTime, formatMoney } from '@/shared/utils/format' + +interface WithdrawFormValues { + amount: string +} + +function errorText(error: unknown) { + const response = (error as { response?: { data?: { message?: string } } })?.response + if (response?.data?.message) return response.data.message + return error instanceof Error ? error.message : '请求失败,请稍后重试' +} + +export function MobileWithdrawalPage() { + const navigate = useNavigate() + const queryClient = useQueryClient() + const [form] = Form.useForm() + const [accountId, setAccountId] = useState() + const [submitting, setSubmitting] = useState(false) + const user = useSessionStore(state => state.user) + + const balanceQuery = useQuery({ + queryKey: ['wallet-balance'], + queryFn: fetchWalletBalance, + enabled: Boolean(user), + }) + const accountsQuery = useQuery({ + queryKey: ['payment-accounts', 1], + queryFn: () => fetchPaymentAccounts(1, 50), + enabled: Boolean(user), + }) + const withdrawalsQuery = useQuery({ + queryKey: ['withdrawals', 1, 20], + queryFn: () => fetchWithdrawals(1, 20), + enabled: Boolean(user), + }) + + const available = balanceQuery.data?.available_balance_cent ?? 0 + const accounts = useMemo(() => accountsQuery.data?.items ?? [], [accountsQuery.data]) + const withdrawals = withdrawalsQuery.data?.items ?? [] + + useEffect(() => { + if (accountId) return + const preferred = accounts.find(item => item.is_default) ?? accounts[0] + if (preferred) setAccountId(preferred.id) + }, [accounts, accountId]) + + const submit = async () => { + const values = await form.validateFields() + if (!accountId) { + Toast.show({ content: '请先选择收款账户' }) + return + } + const amountCent = Math.round(Number(values.amount) * 100) + if (!Number.isFinite(amountCent) || amountCent <= 0) { + Toast.show({ content: '请输入正确的提现金额' }) + return + } + if (amountCent > available) { + Toast.show({ content: '提现金额不能超过可用余额' }) + return + } + setSubmitting(true) + try { + await createWithdrawal({ payment_account_id: accountId, amount_cent: amountCent }) + Toast.show({ icon: 'success', content: '提现申请已提交' }) + form.setFieldValue('amount', '') + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['withdrawals'] }), + queryClient.invalidateQueries({ queryKey: ['wallet-balance'] }), + queryClient.invalidateQueries({ queryKey: ['wallet-ledger'] }), + ]) + } catch (error) { + Toast.show({ content: errorText(error) }) + } finally { + setSubmitting(false) + } + } + + const cancel = async (id: number, withdrawNo: string) => { + const confirmed = await Dialog.confirm({ + content: `确认取消提现 ${withdrawNo}?取消后金额退回可用余额。`, + confirmText: '取消提现', + cancelText: '返回', + }) + if (!confirmed) return + try { + await cancelWithdrawal(id) + Toast.show({ icon: 'success', content: '提现已取消' }) + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['withdrawals'] }), + queryClient.invalidateQueries({ queryKey: ['wallet-balance'] }), + ]) + } catch (error) { + Toast.show({ content: errorText(error) }) + } + } + + return ( +
+ navigate('/m/profile')}>提现 + + +
可用余额
+ {balanceQuery.isPending ? ( + + ) : ( +
{formatMoney(available)}
+ )} + +
+ + {accounts.length === 0 && !accountsQuery.isPending ? ( + + ) : ( + <> + + {accountsQuery.isPending ? ( + + ) : ( + setAccountId(values[0] ? Number(values[0]) : undefined)} + options={accounts.map(account => ({ + label: `${paymentAccountTypeLabel(account.account_type)} · ${account.account_name}`, + description: account.account_no, + value: String(account.id), + }))} + /> + )} + + + +
+ + + +
+
+ 可提现 {formatMoney(available)},当前手续费 0%,到账金额以审核结果为准。 +
+ +
+ + )} + + + {withdrawalsQuery.isPending ? ( + + ) : withdrawals.length === 0 ? ( + + ) : ( + + {withdrawals.map(item => { + const meta = withdrawalStatusMeta(item.status) + return ( + + {meta.label} + + } + clickable={item.status === 'pending'} + onClick={() => { + if (item.status === 'pending') void cancel(item.id, item.withdraw_no) + }} + > +
{formatMoney(item.amount_cent)}
+
+ {item.withdraw_no} · 到账 {formatMoney(item.actual_amount_cent)} +
+
+ ) + })} +
+ )} +
+
+ ) +} diff --git a/frontend/src/features/seller/SellerEarningsPage.tsx b/frontend/src/features/seller/SellerEarningsPage.tsx new file mode 100644 index 0000000..52f9a7c --- /dev/null +++ b/frontend/src/features/seller/SellerEarningsPage.tsx @@ -0,0 +1,161 @@ +import { useState } from 'react' +import { keepPreviousData, useQuery } from '@tanstack/react-query' +import { Alert, Button, Space, Table, Tag, Typography, type TableColumnsType } from 'antd' +import { ReloadOutlined } from '@ant-design/icons' +import { useNavigate } from 'react-router-dom' + +import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader' +import { fetchWalletBalance, fetchWalletLedger } from '@/features/wallet/api' +import { ledgerBalanceTypeLabel, walletBizTypeLabel } from '@/features/wallet/labels' +import type { WalletLedger } from '@/features/wallet/types' +import { useSessionStore } from '@/stores/session' +import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format' + +const columns: TableColumnsType = [ + { + title: '流水号', + dataIndex: 'ledger_no', + width: 220, + render: value => {formatText(value)}, + }, + { + title: '业务', + dataIndex: 'biz_type', + width: 150, + render: value => {walletBizTypeLabel(value)}, + }, + { + title: '方向', + dataIndex: 'direction', + width: 90, + render: value => ( + + {value === 'income' ? '入账' : '出账'} + + ), + }, + { + title: '金额', + dataIndex: 'amount_cent', + width: 140, + align: 'right', + render: (value: number, row) => ( + + {row.direction === 'income' ? '+' : '-'} + {formatMoney(Math.abs(value))} + + ), + }, + { + title: '余额类型', + dataIndex: 'balance_type', + width: 120, + render: value => ( + {ledgerBalanceTypeLabel(value)} + ), + }, + { + title: '变化后余额', + dataIndex: 'balance_after_cent', + width: 150, + align: 'right', + render: value => formatMoney(value), + }, + { + title: '备注', + dataIndex: 'remark', + ellipsis: true, + render: value => formatText(value), + }, + { + title: '时间', + dataIndex: 'created_at', + width: 170, + render: value => formatDateTime(value), + }, +] + +export function SellerEarningsPage() { + const navigate = useNavigate() + const user = useSessionStore(state => state.user) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + + const balanceQuery = useQuery({ + queryKey: ['wallet-balance'], + queryFn: fetchWalletBalance, + enabled: Boolean(user), + }) + const ledgerQuery = useQuery({ + queryKey: ['wallet-ledger', page, pageSize], + queryFn: () => fetchWalletLedger(page, pageSize), + enabled: Boolean(user), + placeholderData: keepPreviousData, + }) + + return ( +
+ + 可用 {formatMoney(balanceQuery.data?.available_balance_cent ?? 0)} + 冻结 {formatMoney(balanceQuery.data?.frozen_balance_cent ?? 0)} + + } + extra={ + + + + + + } + /> + + {ledgerQuery.isError ? ( + + ) : null} + + + rowKey="id" + size="middle" + columns={columns} + dataSource={ledgerQuery.data?.items ?? []} + loading={ledgerQuery.isFetching} + scroll={{ x: 'max-content' }} + locale={{ emptyText: '还没有收益流水' }} + pagination={{ + current: ledgerQuery.data?.page ?? page, + pageSize: ledgerQuery.data?.page_size ?? pageSize, + total: ledgerQuery.data?.total ?? 0, + showSizeChanger: true, + pageSizeOptions: ['10', '20', '50'], + showTotal: (count, range) => `第 ${range[0]}-${range[1]} 条 / 共 ${count} 条`, + onChange: (nextPage, nextSize) => { + setPage(nextPage) + setPageSize(nextSize) + }, + }} + /> +
+ ) +} diff --git a/frontend/src/features/seller/SellerHandoffsPage.tsx b/frontend/src/features/seller/SellerHandoffsPage.tsx new file mode 100644 index 0000000..42f048f --- /dev/null +++ b/frontend/src/features/seller/SellerHandoffsPage.tsx @@ -0,0 +1,195 @@ +import { useState } from 'react' +import { keepPreviousData, useQuery } from '@tanstack/react-query' +import { Alert, Button, Card, Select, Space, Tag, Typography, type TableColumnsType } from 'antd' +import { ReloadOutlined, RightOutlined } from '@ant-design/icons' +import { useNavigate } from 'react-router-dom' + +import { DataTable } from '@/features/admin/components/DataTable' +import { fetchSellerHandoffs } from '@/features/seller/api' +import { handoffStatusMeta, orderStatusMeta } from '@/features/orders/labels' +import type { UserOrderListItem } from '@/features/orders/types' +import { useSessionStore } from '@/stores/session' +import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format' + +const statusOptions = [ + { value: '', label: '全部' }, + { value: 'pending_handoff', label: '待交接' }, + { value: 'pending_checkout_accept', label: '待确认结账' }, + { value: 'abnormal', label: '异常订单' }, +] + +export function SellerHandoffsPage() { + const navigate = useNavigate() + const user = useSessionStore(state => state.user) + const [status, setStatus] = useState('') + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + + const handoffsQuery = useQuery({ + queryKey: ['seller-handoffs', status, page, pageSize], + queryFn: () => fetchSellerHandoffs(page, pageSize, status), + enabled: Boolean(user), + placeholderData: keepPreviousData, + }) + + const metrics = handoffsQuery.data?.metrics + + const columns: TableColumnsType = [ + { + title: '订单', + key: 'order', + width: 260, + render: (_, row) => ( +
+ + {row.title} + {row.listing_no} +
+ ), + }, + { + title: '状态', + key: 'status', + width: 200, + render: (_, row) => ( + + {orderStatusMeta(row.status).label} + + {handoffStatusMeta(row.handoff_status).label} + + + ), + }, + { + title: '商品信息', + key: 'listing', + render: (_, row) => ( +
+ {formatText(row.server_region)} + {formatText(row.login_platform)} +
+ ), + }, + { + title: '金额', + key: 'amount', + width: 150, + align: 'right', + render: (_, row) => ( +
+ {formatMoney(row.display_amount_cent)} + 押金 {formatMoney(row.deposit_amount_cent)} +
+ ), + }, + { + title: '创建时间', + dataIndex: 'created_at', + width: 170, + render: value => formatDateTime(value), + }, + { + title: '操作', + key: 'actions', + width: 100, + fixed: 'right', + render: (_, row) => ( + + ), + }, + ] + + return ( +
+
+
+ JIEYOU · 卖家中心 + + 号主待办 + + + 处理待交接、待确认结账与异常订单;点击订单进入详情完成后续操作。 + +
+
+ + { + setStatus(value) + setPage(1) + }} + /> + + + } + /> + + {pickupsQuery.isError ? ( + + ) : null} + + + columns={columns} + dataSource={pickupsQuery.data?.items ?? []} + rowKey="id" + loading={pickupsQuery.isFetching} + total={pickupsQuery.data?.total ?? 0} + page={pickupsQuery.data?.page ?? page} + pageSize={pickupsQuery.data?.page_size ?? pageSize} + onPageChange={(nextPage, nextSize) => { + setPage(nextPage) + setPageSize(nextSize) + }} + emptyDescription="还没有线下提号记录" + /> +
+ ) +} diff --git a/frontend/src/features/seller/api.ts b/frontend/src/features/seller/api.ts index 76f4974..e1440ff 100644 --- a/frontend/src/features/seller/api.ts +++ b/frontend/src/features/seller/api.ts @@ -59,3 +59,65 @@ export function publishResultText(listing: Listing, editing: boolean) { if (published) return editing ? '修改成功,已上架' : '发布成功,已上架' return editing ? '修改成功,等待后台审核' : '发布成功,等待后台审核' } + +export interface SellerHandoffMetrics { + pending_handoff: number + pending_checkout: number + abnormal: number +} + +export interface SellerHandoffPage { + items: T[] + total: number + page: number + page_size: number + metrics: SellerHandoffMetrics +} + +export interface SellerPickup { + id: number + pickup_no: string + listing_no: string + account_title: string + server_region: string + login_platform: string + operator: string + platform: string + status: string + offline_settlement_status: string + order_amount_cent: number + settle_amount_cent: number + effective_settle_amount_cent: number + profit_amount_cent: number + created_at: string + completed_at?: string | null + cancelled_at?: string | null +} + +export interface SellerPickupPage { + items: SellerPickup[] + total: number + page: number + page_size: number +} + +export async function fetchSellerHandoffs(page = 1, pageSize = 20, status = '') { + return unwrapData>( + apiClient.get>>('/orders/handoffs', { + params: { page, page_size: pageSize, status: status || undefined }, + }) + ) +} + +export async function fetchSellerPickups(page = 1, pageSize = 20, status = '') { + return unwrapData>( + apiClient.get>>('/seller/pickups', { + params: { page, page_size: pageSize, status: status || undefined }, + }) + ).then(data => ({ + items: data.items ?? [], + total: data.total ?? 0, + page: data.page ?? page, + page_size: data.page_size ?? pageSize, + })) +} diff --git a/frontend/src/features/wallet/PaymentAccountsPage.tsx b/frontend/src/features/wallet/PaymentAccountsPage.tsx new file mode 100644 index 0000000..62d024e --- /dev/null +++ b/frontend/src/features/wallet/PaymentAccountsPage.tsx @@ -0,0 +1,375 @@ +import { useState } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { + App, + Button, + Card, + Empty, + Form, + Input, + Modal, + Select, + Space, + Tag, + Typography, + Upload, + type UploadFile, +} from 'antd' +import { + DeleteOutlined, + EditOutlined, + PlusOutlined, + ReloadOutlined, + StarFilled, + StarOutlined, +} from '@ant-design/icons' +import { useNavigate } from 'react-router-dom' + +import { uploadFile } from '@/shared/api/files' +import { + createPaymentAccount, + deletePaymentAccount, + fetchPaymentAccounts, + setDefaultPaymentAccount, + updatePaymentAccount, +} from '@/features/wallet/api' +import { paymentAccountTypeLabel } from '@/features/wallet/labels' +import type { + CreatePaymentAccountPayload, + PaymentAccount, + PaymentAccountType, +} from '@/features/wallet/types' +import { useSessionStore } from '@/stores/session' +import { formatDateTime, formatText } from '@/shared/utils/format' + +interface AccountFormValues { + account_type: PaymentAccountType + account_name: string + account_no: string + bank_name?: string + bank_branch?: string +} + +function errorText(error: unknown) { + const response = (error as { response?: { data?: { message?: string } } })?.response + if (response?.data?.message) return response.data.message + return error instanceof Error ? error.message : '请求失败,请稍后重试' +} + +export function PaymentAccountsPage() { + const navigate = useNavigate() + const { message, modal } = App.useApp() + const queryClient = useQueryClient() + const [form] = Form.useForm() + const [editorOpen, setEditorOpen] = useState(false) + const [editing, setEditing] = useState() + const [certificates, setCertificates] = useState([]) + const [uploading, setUploading] = useState(false) + const [submitting, setSubmitting] = useState(false) + const user = useSessionStore(state => state.user) + const accountType = Form.useWatch('account_type', form) + + const accountsQuery = useQuery({ + queryKey: ['payment-accounts', 1], + queryFn: () => fetchPaymentAccounts(1, 50), + enabled: Boolean(user), + }) + + const accounts = accountsQuery.data?.items ?? [] + + const invalidate = () => queryClient.invalidateQueries({ queryKey: ['payment-accounts'] }) + + const openCreate = () => { + setEditing(undefined) + setCertificates([]) + form.setFieldsValue({ + account_type: 'alipay', + account_name: '', + account_no: '', + bank_name: '', + bank_branch: '', + }) + setEditorOpen(true) + } + + const openEdit = (account: PaymentAccount) => { + setEditing(account) + setCertificates(account.certificate_urls ?? []) + form.setFieldsValue({ + account_type: account.account_type, + account_name: account.account_name, + account_no: account.account_no, + bank_name: account.bank_name, + bank_branch: account.bank_branch, + }) + setEditorOpen(true) + } + + const save = async () => { + const values = await form.validateFields() + setSubmitting(true) + try { + if (editing) { + await updatePaymentAccount(editing.id, { + bank_branch: values.bank_branch?.trim(), + certificate_urls: certificates, + }) + message.success('收款账户已更新') + } else { + const payload: CreatePaymentAccountPayload = { + account_type: values.account_type, + account_name: values.account_name.trim(), + account_no: values.account_no.trim(), + bank_name: values.bank_name?.trim(), + bank_branch: values.bank_branch?.trim(), + certificate_urls: certificates, + } + await createPaymentAccount(payload) + message.success('收款账户已添加') + } + setEditorOpen(false) + await invalidate() + } catch (error) { + message.error(errorText(error)) + } finally { + setSubmitting(false) + } + } + + const remove = (account: PaymentAccount) => { + modal.confirm({ + title: `确认删除收款账户「${account.account_name}」?`, + content: '删除后该账户不再出现在提现选择列表中,已提交的提现不受影响。', + okText: '删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await deletePaymentAccount(account.id) + message.success('收款账户已删除') + await invalidate() + } catch (error) { + message.error(errorText(error)) + } + }, + }) + } + + const setDefault = async (account: PaymentAccount) => { + try { + await setDefaultPaymentAccount(account.id) + message.success('已设为默认收款账户') + await invalidate() + } catch (error) { + message.error(errorText(error)) + } + } + + const handleUpload = async (file: File) => { + setUploading(true) + try { + const uploaded = await uploadFile(file, 'payment-cert') + setCertificates(current => [...current, uploaded.url]) + message.success('收款凭证已上传') + } catch (error) { + message.error(errorText(error)) + } finally { + setUploading(false) + } + return false + } + + const fileList: UploadFile[] = certificates.map((url, index) => ({ + uid: `${index}`, + name: `收款凭证 ${index + 1}`, + status: 'done', + url, + thumbUrl: url, + })) + + return ( +
+
+
+ JIEYOU · 资金 + + 收款账户 + + + 提现前需维护至少一个收款账户;默认账户会在提现表单中自动选中。 + +
+
+ 可用账户 + {accounts.length} + + + + +
+
+ + } + loading={accountsQuery.isFetching} + onClick={() => void accountsQuery.refetch()} + > + 刷新 + + } + > + {accounts.length === 0 ? ( + + ) : ( +
+ {accounts.map(account => ( +
+
+ + + {paymentAccountTypeLabel(account.account_type)} + + {account.is_default ? ( + + 默认 + + ) : null} + + {account.account_no} +
+ {account.account_name} + {account.bank_name ? ( + + {account.bank_name} + {account.bank_branch ? ` · ${account.bank_branch}` : ''} + + ) : null} + + 添加于 {formatDateTime(account.created_at)} + + {account.certificate_urls?.length ? ( + + 凭证 {account.certificate_urls.length} 张 + + ) : null} + + + + + +
+ ))} +
+ )} +
+ + void save()} + onCancel={() => (submitting ? undefined : setEditorOpen(false))} + maskClosable={!submitting} + > +
+ + + + + + + {accountType === 'bank' ? ( + <> + + + + + + + + ) : null} + + { + void handleUpload(file as File) + return false + }} + onRemove={file => { + const index = Number(file.uid) + setCertificates(current => current.filter((_, i) => i !== index)) + }} + > + {certificates.length >= 5 || uploading ? null : ( +
+ +
上传
+
+ )} +
+
+
+
+ + + 当前共 {accounts.length} 个账户,其中默认账户 {accounts.filter(item => item.is_default).length} 个 + {formatText('')} + +
+ ) +} diff --git a/frontend/src/features/wallet/WalletPage.tsx b/frontend/src/features/wallet/WalletPage.tsx new file mode 100644 index 0000000..b274037 --- /dev/null +++ b/frontend/src/features/wallet/WalletPage.tsx @@ -0,0 +1,215 @@ +import { useState } from 'react' +import { keepPreviousData, useQuery } from '@tanstack/react-query' +import { Alert, Button, Card, Space, Table, Tag, Typography, type TableColumnsType } from 'antd' +import { ArrowDownOutlined, ArrowUpOutlined, ReloadOutlined } from '@ant-design/icons' +import { useNavigate } from 'react-router-dom' + +import { fetchWalletBalance, fetchWalletLedger } from '@/features/wallet/api' +import { + ledgerBalanceTypeLabel, + walletBizTypeLabel, + walletStatusLabel, +} from '@/features/wallet/labels' +import type { WalletLedger } from '@/features/wallet/types' +import { useSessionStore } from '@/stores/session' +import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format' + +const PAGE_SIZE = 20 + +function directionTag(value?: string | null) { + if (value === 'income') { + return ( + } bordered={false}> + 入账 + + ) + } + return ( + } bordered={false}> + 出账 + + ) +} + +const columns: TableColumnsType = [ + { + title: '流水号', + dataIndex: 'ledger_no', + width: 210, + render: value => {formatText(value)}, + }, + { + title: '业务', + dataIndex: 'biz_type', + width: 140, + render: value => {walletBizTypeLabel(value)}, + }, + { + title: '方向', + dataIndex: 'direction', + width: 100, + render: value => directionTag(value), + }, + { + title: '金额', + dataIndex: 'amount_cent', + width: 140, + align: 'right', + render: (value: number, row) => ( + + {row.direction === 'income' ? '+' : '-'} + {formatMoney(Math.abs(value))} + + ), + }, + { + title: '余额类型', + dataIndex: 'balance_type', + width: 120, + render: value => ( + {ledgerBalanceTypeLabel(value)} + ), + }, + { + title: '变化后余额', + dataIndex: 'balance_after_cent', + width: 150, + align: 'right', + render: value => formatMoney(value), + }, + { + title: '备注', + dataIndex: 'remark', + ellipsis: true, + render: value => formatText(value), + }, + { + title: '时间', + dataIndex: 'created_at', + width: 170, + render: value => formatDateTime(value), + }, +] + +export function WalletPage() { + const navigate = useNavigate() + const user = useSessionStore(state => state.user) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(PAGE_SIZE) + + const balanceQuery = useQuery({ + queryKey: ['wallet-balance'], + queryFn: fetchWalletBalance, + enabled: Boolean(user), + }) + const ledgerQuery = useQuery({ + queryKey: ['wallet-ledger', page, pageSize], + queryFn: () => fetchWalletLedger(page, pageSize), + enabled: Boolean(user), + placeholderData: keepPreviousData, + }) + + const account = balanceQuery.data + const ledger = ledgerQuery.data + + return ( +
+
+
+ JIEYOU · 资金 + + 资金账户 + + + 查看卖家结算收入、冻结押金与每一笔资金变化;提现需先维护收款账户。 + +
+
+ 当前可用 + {formatMoney(account?.available_balance_cent ?? 0)} + + + + +
+
+ +
+
+ 可用余额 + {formatMoney(account?.available_balance_cent ?? 0)} + 可随时申请提现 +
+
+ 冻结余额 + {formatMoney(account?.frozen_balance_cent ?? 0)} + 提现处理中或订单押金暂扣 +
+
+ 账户状态 + {walletStatusLabel(account?.status)} + 异常时请联系客服核实 +
+
+ + {balanceQuery.isError ? ( + + ) : null} + + + + 资金流水 + + + 共 {ledger?.total ?? 0} 条记录,最近变动优先展示。 + +
+ } + extra={ + + } + > + + rowKey="id" + size="middle" + columns={columns} + dataSource={ledger?.items ?? []} + loading={ledgerQuery.isFetching} + scroll={{ x: 'max-content' }} + locale={{ emptyText: '暂无资金流水' }} + pagination={{ + current: ledger?.page ?? page, + pageSize: ledger?.page_size ?? pageSize, + total: ledger?.total ?? 0, + showSizeChanger: true, + pageSizeOptions: ['10', '20', '50'], + showTotal: (count, range) => `第 ${range[0]}-${range[1]} 条 / 共 ${count} 条`, + onChange: (nextPage, nextSize) => { + setPage(nextPage) + setPageSize(nextSize) + }, + }} + /> + +
+ ) +} diff --git a/frontend/src/features/wallet/WithdrawalPage.tsx b/frontend/src/features/wallet/WithdrawalPage.tsx new file mode 100644 index 0000000..e77cb97 --- /dev/null +++ b/frontend/src/features/wallet/WithdrawalPage.tsx @@ -0,0 +1,347 @@ +import { useEffect, useMemo, useState } from 'react' +import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query' +import { + Alert, + App, + Button, + Card, + Form, + InputNumber, + Select, + Space, + Table, + Tag, + Typography, + type TableColumnsType, +} from 'antd' +import { ReloadOutlined } from '@ant-design/icons' +import { useNavigate } from 'react-router-dom' + +import { + cancelWithdrawal, + createWithdrawal, + fetchPaymentAccounts, + fetchWalletBalance, + fetchWithdrawals, +} from '@/features/wallet/api' +import { + paymentAccountTypeLabel, + withdrawalStatusMeta, +} from '@/features/wallet/labels' +import type { PaymentAccount, WithdrawalRecord } from '@/features/wallet/types' +import { useSessionStore } from '@/stores/session' +import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format' + +const PAGE_SIZE = 20 + +interface WithdrawFormValues { + payment_account_id: number + amount_yuan: number +} + +function errorText(error: unknown) { + const response = (error as { response?: { data?: { message?: string } } })?.response + if (response?.data?.message) return response.data.message + return error instanceof Error ? error.message : '请求失败,请稍后重试' +} + +const columns: TableColumnsType = [ + { + title: '提现单号', + dataIndex: 'withdraw_no', + width: 200, + render: value => {formatText(value)}, + }, + { + title: '提现金额', + dataIndex: 'amount_cent', + width: 130, + align: 'right', + render: value => {formatMoney(value)}, + }, + { + title: '手续费 / 到账', + key: 'fee', + width: 170, + align: 'right', + render: (_, row) => ( +
+ {formatMoney(row.actual_amount_cent)} + 手续费 {formatMoney(row.fee_cent)} +
+ ), + }, + { + title: '收款方式', + key: 'account', + width: 190, + render: (_, row) => ( +
+ {paymentAccountTypeLabel(row.account_type)} + + {formatText(row.account_name)} · {formatText(row.account_no)} + +
+ ), + }, + { + title: '状态', + dataIndex: 'status', + width: 110, + render: value => { + const meta = withdrawalStatusMeta(value) + return ( + + {meta.label} + + ) + }, + }, + { + title: '备注', + dataIndex: 'review_remark', + ellipsis: true, + render: value => formatText(value), + }, + { + title: '申请时间', + dataIndex: 'created_at', + width: 170, + render: value => formatDateTime(value), + }, +] + +export function WithdrawalPage() { + const navigate = useNavigate() + const { message, modal } = App.useApp() + const queryClient = useQueryClient() + const [form] = Form.useForm() + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(PAGE_SIZE) + const [submitting, setSubmitting] = useState(false) + const user = useSessionStore(state => state.user) + const amountYuan = Form.useWatch('amount_yuan', form) + + const balanceQuery = useQuery({ + queryKey: ['wallet-balance'], + queryFn: fetchWalletBalance, + enabled: Boolean(user), + }) + const accountsQuery = useQuery({ + queryKey: ['payment-accounts', 1], + queryFn: () => fetchPaymentAccounts(1, 50), + enabled: Boolean(user), + }) + const withdrawalsQuery = useQuery({ + queryKey: ['withdrawals', page, pageSize], + queryFn: () => fetchWithdrawals(page, pageSize), + enabled: Boolean(user), + placeholderData: keepPreviousData, + }) + + const available = balanceQuery.data?.available_balance_cent ?? 0 + const accounts: PaymentAccount[] = accountsQuery.data?.items ?? [] + const withdrawals = withdrawalsQuery.data + + const accountOptions = useMemo( + () => + accounts.map(account => ({ + value: account.id, + label: `${paymentAccountTypeLabel(account.account_type)} · ${account.account_name} (${account.account_no})`, + })), + [accounts] + ) + + useEffect(() => { + if (form.getFieldValue('payment_account_id')) return + const preferred = accounts.find(item => item.is_default) ?? accounts[0] + if (preferred) form.setFieldValue('payment_account_id', preferred.id) + }, [accounts, form]) + + const submit = async () => { + const values = await form.validateFields() + const amountCent = Math.round(Number(values.amount_yuan) * 100) + if (amountCent <= 0) { + message.warning('提现金额必须大于 0') + return + } + if (amountCent > available) { + message.warning('提现金额不能超过可用余额') + return + } + setSubmitting(true) + try { + await createWithdrawal({ + payment_account_id: values.payment_account_id, + amount_cent: amountCent, + }) + message.success('提现申请已提交,等待审核') + form.setFieldValue('amount_yuan', undefined) + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['withdrawals'] }), + queryClient.invalidateQueries({ queryKey: ['wallet-balance'] }), + queryClient.invalidateQueries({ queryKey: ['wallet-ledger'] }), + ]) + } catch (error) { + message.error(errorText(error)) + } finally { + setSubmitting(false) + } + } + + const cancel = (record: WithdrawalRecord) => { + modal.confirm({ + title: `确认取消提现「${record.withdraw_no}」?`, + content: '取消后冻结金额会退回可用余额,需重新提交提现申请。', + okText: '取消提现', + okButtonProps: { danger: true }, + cancelText: '返回', + onOk: async () => { + try { + await cancelWithdrawal(record.id) + message.success('提现已取消') + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['withdrawals'] }), + queryClient.invalidateQueries({ queryKey: ['wallet-balance'] }), + ]) + } catch (error) { + message.error(errorText(error)) + } + }, + }) + } + + const rows: WithdrawalRecord[] = withdrawals?.items ?? [] + + return ( +
+
+
+ JIEYOU · 资金 + + 申请提现 + + + 提现申请提交后进入人工审核,审核通过后由财务打款到所选收款账户。 + +
+
+ 当前可用 + {formatMoney(available)} + + + + +
+
+ + {accounts.length === 0 ? ( + navigate('/wallet/payment-accounts')}> + 去添加 + + } + /> + ) : null} + + +
+ +