feat(wallet): 补齐钱包提现与卖家资金页面

This commit is contained in:
yml2213
2026-10-30 16:41:34 +08:00
parent a39a504b81
commit c0c9f161ae
23 changed files with 3348 additions and 0 deletions
@@ -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<FinanceDetail> = [
{
title: '订单',
key: 'order',
width: 230,
fixed: 'left',
render: (_, row) => (
<div className="admin-stack">
<strong>{row.order_no}</strong>
<Space size={4} wrap>
<Tag bordered={false}>{orderStatusMap[row.order_status]?.label ?? row.order_status}</Tag>
<Tag bordered={false} color={row.finance_status === 'settled' ? 'green' : 'gold'}>
{row.finance_status === 'settled' ? '已结算' : '待结算'}
</Tag>
</Space>
</div>
),
},
{
title: '租客',
key: 'renter',
width: 160,
render: (_, row) => (
<div className="admin-stack">
<span>{formatText(row.renter_nickname)}</span>
<span className="admin-mono">{formatText(row.renter_phone)}</span>
</div>
),
},
{
title: '号主',
key: 'owner',
width: 160,
render: (_, row) => (
<div className="admin-stack">
<span>{formatText(row.owner_nickname)}</span>
<span className="admin-mono">{formatText(row.owner_phone)}</span>
</div>
),
},
{
title: '实付 / 退款',
key: 'amount',
width: 160,
align: 'right',
render: (_, row) => (
<div className="admin-stack">
<span>{formatMoney(row.paid_amount_cent)}</span>
<Typography.Text type="secondary">
退款 {formatMoney(row.refunded_amount_cent)}
</Typography.Text>
</div>
),
},
{
title: '号主结算',
key: 'owner_settle',
width: 150,
align: 'right',
render: (_, row) => (
<div className="admin-stack">
<span>{formatMoney(row.owner_effective_settlement_amount_cent)}</span>
<Typography.Text type="secondary">
钱包 {formatMoney(row.owner_wallet_income_amount_cent)}
</Typography.Text>
</div>
),
},
{
title: '平台净额',
dataIndex: 'platform_net_amount_cent',
width: 130,
align: 'right',
render: value => formatMoney(value),
},
{
title: '结算模式',
key: 'mode',
width: 150,
render: (_, row) => (
<div className="admin-stack">
<Tag bordered={false}>{formatText(row.settlement_mode)}</Tag>
<span className="admin-mono">{formatText(row.offline_settlement_status)}</span>
</div>
),
},
{
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<Filters>(emptyFilters)
const [applied, setApplied] = useState<Filters>(emptyFilters)
const [settlementStatus, setSettlementStatus] = useState<string>()
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 (
<div className="admin-page">
<AdminPageHeader title="资金明细" eyebrow="JIEYOU OPS · 财务" />
<Alert type="warning" showIcon message="当前账号没有资金查看权限" />
</div>
)
}
return (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 财务"
title="资金明细"
description="按订单维度核对实付、退款、号主结算与平台净额。"
meta={
<Space size={8} wrap>
<Tag color="geekblue">共 {detailsQuery.data?.total ?? 0} 单</Tag>
</Space>
}
extra={
<Button
icon={<ReloadOutlined />}
loading={detailsQuery.isFetching}
onClick={() => void detailsQuery.refetch()}
>
刷新
</Button>
}
/>
<FilterBar
loading={detailsQuery.isFetching}
onSearch={() => {
setPage(1)
setApplied(filters)
}}
onReset={() => {
setFilters(emptyFilters)
setApplied(emptyFilters)
setSettlementStatus(undefined)
setPage(1)
}}
>
<FilterField label="订单号">
<Input
allowClear
value={filters.order_no}
placeholder="完整订单号"
onChange={event =>
setFilters(current => ({ ...current, order_no: event.target.value }))
}
/>
</FilterField>
<FilterField label="日期口径">
<Select
value={filters.date_type}
options={[
{ value: 'settled', label: '按结算时间' },
{ value: 'created', label: '按下单时间' },
{ value: 'offline_settled', label: '按线下结算时间' },
]}
onChange={value => setFilters(current => ({ ...current, date_type: value }))}
/>
</FilterField>
<FilterField label="日期区间" width={280}>
<DatePicker.RangePicker
style={{ width: '100%' }}
value={filters.range}
allowEmpty={[true, true]}
onChange={value =>
setFilters(current => ({
...current,
range: value && value[0] && value[1] ? [value[0], value[1]] : null,
}))
}
/>
</FilterField>
<FilterField label="结算状态">
<Select
allowClear
value={settlementStatus}
options={settlementStatusOptions}
placeholder="全部状态"
onChange={value => setSettlementStatus(value)}
/>
</FilterField>
</FilterBar>
{detailsQuery.isError ? (
<Alert
type="error"
showIcon
className="admin-table-alert"
message="资金明细加载失败"
description={errorText(detailsQuery.error)}
/>
) : null}
<DataTable<FinanceDetail>
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="所选区间还没有资金明细"
/>
</div>
)
}
@@ -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<AdminPaymentQuery, 'order_no' | 'biz_type' | 'status' | 'provider'>
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<string, { label: string; color: string }> = {
pending: { label: '待支付', color: 'gold' },
paid: { label: '已支付', color: 'green' },
failed: { label: '支付失败', color: 'red' },
closed: { label: '已关闭', color: 'default' },
}
function statusTag(value?: string | null) {
if (!value) return <Tag>未知</Tag>
const meta = statusMeta[value] ?? { label: value, color: 'default' }
return (
<Tag bordered={false} color={meta.color}>
{meta.label}
</Tag>
)
}
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<AdminPaymentRow> = [
{
title: '支付单号',
key: 'payment',
width: 240,
fixed: 'left',
render: (_, row) => (
<div className="admin-stack">
<strong>{row.payment_no}</strong>
<span className="admin-mono">{formatText(row.order_no)}</span>
</div>
),
},
{
title: '用户',
key: 'user',
width: 170,
render: (_, row) => (
<div className="admin-stack">
<span>{formatText(row.user_phone)}</span>
<span className="admin-mono">UID {row.user_id}</span>
</div>
),
},
{
title: '渠道 / 支付方式',
key: 'channel',
width: 170,
render: (_, row) => (
<div className="admin-stack">
<span>{formatText(row.provider)}</span>
<span className="admin-mono">{formatText(row.pay_way)}</span>
</div>
),
},
{
title: '金额',
dataIndex: 'amount_cent',
width: 130,
align: 'right',
render: value => <strong>{formatMoney(value)}</strong>,
},
{
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 ? (
<Typography.Text type="danger">
{row.error_code ? `${row.error_code} · ` : ''}
{row.error_message}
</Typography.Text>
) : (
<Typography.Text type="secondary">—</Typography.Text>
),
},
{
title: '创建时间',
dataIndex: 'created_at',
width: 170,
render: value => formatDateTime(value),
},
]
export function AdminPaymentsPage() {
const [filters, setFilters] = useState<Filters>(emptyFilters)
const [applied, setApplied] = useState<Filters>(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 (
<div className="admin-page">
<AdminPageHeader title="支付流水" eyebrow="JIEYOU OPS · 财务" />
<Alert type="warning" showIcon message="当前账号没有资金查看权限" />
</div>
)
}
return (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 财务"
title="支付流水"
description="查看订单支付、充值等渠道流水,核对金额与回调状态。"
meta={
<Space size={8} wrap>
<Tag color="geekblue">共 {paymentsQuery.data?.total ?? 0} 条</Tag>
</Space>
}
extra={
<Button
icon={<ReloadOutlined />}
loading={paymentsQuery.isFetching}
onClick={() => void paymentsQuery.refetch()}
>
刷新
</Button>
}
/>
<FilterBar
loading={paymentsQuery.isFetching}
onSearch={() => {
setPage(1)
setApplied(filters)
}}
onReset={() => {
setFilters(emptyFilters)
setApplied(emptyFilters)
setPage(1)
}}
>
<FilterField label="订单号">
<Input
allowClear
value={filters.order_no}
placeholder="支持完整订单号"
onChange={event =>
setFilters(current => ({ ...current, order_no: event.target.value }))
}
/>
</FilterField>
<FilterField label="业务类型">
<Select
allowClear
value={filters.biz_type}
options={bizTypeOptions}
placeholder="全部业务"
onChange={value => setFilters(current => ({ ...current, biz_type: value }))}
/>
</FilterField>
<FilterField label="状态">
<Select
allowClear
value={filters.status}
options={statusOptions}
placeholder="全部状态"
onChange={value => setFilters(current => ({ ...current, status: value }))}
/>
</FilterField>
<FilterField label="渠道">
<Select
allowClear
value={filters.provider}
options={providerOptions}
placeholder="全部渠道"
onChange={value => setFilters(current => ({ ...current, provider: value }))}
/>
</FilterField>
</FilterBar>
{paymentsQuery.isError ? (
<Alert
type="error"
showIcon
className="admin-table-alert"
message="支付流水加载失败"
description={errorText(paymentsQuery.error)}
/>
) : null}
<DataTable<AdminPaymentRow>
columns={columns}
dataSource={paymentsQuery.data?.items ?? []}
rowKey="id"
loading={paymentsQuery.isFetching}
total={paymentsQuery.data?.total ?? 0}
page={paymentsQuery.data?.page ?? page}
pageSize={paymentsQuery.data?.page_size ?? pageSize}
onPageChange={(nextPage, nextSize) => {
setPage(nextPage)
setPageSize(nextSize)
}}
emptyDescription="还没有支付流水"
/>
</div>
)
}
@@ -0,0 +1,221 @@
import { useState } from 'react'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { Alert, Button, InputNumber, Select, Space, Tag, type TableColumnsType } from 'antd'
import { ReloadOutlined } from '@ant-design/icons'
import { fetchAdminWalletLedger } 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 { AdminWalletLedgerQuery, AdminWalletLedgerRow } from '@/features/admin/types'
import { useAdminSessionStore } from '@/stores/adminSession'
import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
const bizTypeOptions = [
{ value: 'order', label: '订单结算' },
{ value: 'order_pay', label: '订单支付' },
{ value: 'withdraw_apply', label: '提现申请' },
{ value: 'withdraw_complete', label: '提现到账' },
{ value: 'admin_pickup', label: '线下提号' },
{ value: 'invite_rebate', 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<AdminWalletLedgerRow> = [
{
title: '流水号',
key: 'ledger',
width: 240,
fixed: 'left',
render: (_, row) => (
<div className="admin-stack">
<strong>{row.ledger_no}</strong>
<span className="admin-mono">{formatText(row.order_no)}</span>
</div>
),
},
{
title: '用户',
key: 'user',
width: 180,
render: (_, row) => (
<div className="admin-stack">
<span>{row.user_nickname || formatText(row.user_phone)}</span>
<span className="admin-mono">UID {row.user_id}</span>
</div>
),
},
{
title: '业务',
dataIndex: 'biz_type',
width: 140,
render: value => <Tag bordered={false}>{formatText(value)}</Tag>,
},
{
title: '方向',
dataIndex: 'direction',
width: 90,
render: value => (
<Tag bordered={false} color={value === 'income' ? 'green' : 'volcano'}>
{value === 'income' ? '入账' : '出账'}
</Tag>
),
},
{
title: '金额',
dataIndex: 'amount_cent',
width: 130,
align: 'right',
render: (value: number, row) => (
<strong style={{ color: row.direction === 'income' ? 'var(--success)' : 'var(--danger)' }}>
{row.direction === 'income' ? '+' : '-'}
{formatMoney(Math.abs(value))}
</strong>
),
},
{
title: '变化后余额',
dataIndex: 'balance_after_cent',
width: 140,
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 AdminWalletLedgerPage() {
const [filters, setFilters] = useState<{ user_id?: number; order_id?: number; biz_type?: string }>({})
const [applied, setApplied] = useState(filters)
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: AdminWalletLedgerQuery = {
user_id: applied.user_id,
order_id: applied.order_id,
biz_type: applied.biz_type,
page,
page_size: pageSize,
}
const ledgerQuery = useQuery({
queryKey: ['admin-wallet-ledger', query],
queryFn: () => fetchAdminWalletLedger(query),
enabled: canView,
placeholderData: keepPreviousData,
})
if (!canView) {
return (
<div className="admin-page">
<AdminPageHeader title="钱包流水" eyebrow="JIEYOU OPS · 财务" />
<Alert type="warning" showIcon message="当前账号没有资金查看权限" />
</div>
)
}
return (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 财务"
title="钱包流水"
description="按用户、订单与业务类型核对每一笔余额变动。"
meta={
<Space size={8} wrap>
<Tag color="geekblue">共 {ledgerQuery.data?.total ?? 0} 条</Tag>
</Space>
}
extra={
<Button
icon={<ReloadOutlined />}
loading={ledgerQuery.isFetching}
onClick={() => void ledgerQuery.refetch()}
>
刷新
</Button>
}
/>
<FilterBar
loading={ledgerQuery.isFetching}
onSearch={() => {
setPage(1)
setApplied(filters)
}}
onReset={() => {
setFilters({})
setApplied({})
setPage(1)
}}
>
<FilterField label="用户 ID" width={160}>
<InputNumber
min={1}
style={{ width: '100%' }}
value={filters.user_id}
onChange={value => setFilters(current => ({ ...current, user_id: value ?? undefined }))}
/>
</FilterField>
<FilterField label="订单 ID" width={160}>
<InputNumber
min={1}
style={{ width: '100%' }}
value={filters.order_id}
onChange={value => setFilters(current => ({ ...current, order_id: value ?? undefined }))}
/>
</FilterField>
<FilterField label="业务类型">
<Select
allowClear
value={filters.biz_type}
options={bizTypeOptions}
placeholder="全部业务"
onChange={value => setFilters(current => ({ ...current, biz_type: value }))}
/>
</FilterField>
</FilterBar>
{ledgerQuery.isError ? (
<Alert
type="error"
showIcon
className="admin-table-alert"
message="钱包流水加载失败"
description={errorText(ledgerQuery.error)}
/>
) : null}
<DataTable<AdminWalletLedgerRow>
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="还没有钱包流水"
/>
</div>
)
}
+31
View File
@@ -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<ApiResponse<Partial<AdminPaymentPage>>>(
'/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<ApiResponse<Partial<AdminWalletLedgerPage>>>(
'/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,
}
}
+75
View File
@@ -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