feat(admin): 新增后台财务看板与财务明细

This commit is contained in:
yml2213
2026-10-30 13:22:31 +08:00
parent ab5dac51f7
commit 4239bec519
10 changed files with 789 additions and 5 deletions
@@ -0,0 +1,534 @@
import { useMemo, useState } from 'react'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import {
Alert,
Button,
Card,
Col,
DatePicker,
Input,
Row,
Select,
Space,
Statistic,
Tag,
Typography,
type TableColumnsType,
} from 'antd'
import {
AccountBookOutlined,
BankOutlined,
ReloadOutlined,
RiseOutlined,
WalletOutlined,
WarningOutlined,
} from '@ant-design/icons'
import dayjs, { type Dayjs } from 'dayjs'
import { useNavigate } from 'react-router-dom'
import { fetchFinanceDashboard, 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 { StatusTag } from '@/features/admin/components/StatusTag'
import { financeStatusMap, orderStatusMap, readStatusMeta } from '@/features/admin/labels'
import type { FinanceDaily, FinanceDetail } from '@/features/admin/types'
import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
type DateType = 'settled' | 'created' | 'offline_settled'
const dateTypeOptions = [
{ value: 'settled', label: '按结算时间' },
{ value: 'created', label: '按下单时间' },
{ value: 'offline_settled', label: '按线下结算时间' },
]
const dailyColumns: TableColumnsType<FinanceDaily> = [
{ title: '日期', dataIndex: 'date', width: 120, fixed: 'left' },
{
title: '流水',
dataIndex: 'total_flow_amount_cent',
width: 130,
align: 'right',
render: value => formatMoney(value),
},
{
title: '退款',
dataIndex: 'total_refund_amount_cent',
width: 120,
align: 'right',
render: value => formatMoney(value),
},
{
title: '渠道净额',
dataIndex: 'channel_net_amount_cent',
width: 130,
align: 'right',
render: value => formatMoney(value),
},
{
title: '平台收入',
dataIndex: 'platform_income_amount_cent',
width: 130,
align: 'right',
render: value => <span className="admin-money">{formatMoney(value)}</span>,
},
{
title: '号主应得',
dataIndex: 'owner_should_income_amount_cent',
width: 130,
align: 'right',
render: value => formatMoney(value),
},
{
title: '已结算订单',
dataIndex: 'settled_order_count',
width: 110,
align: 'right',
},
{
title: '支付订单',
dataIndex: 'paid_order_count',
width: 100,
align: 'right',
},
{
title: '运营支出',
dataIndex: 'operating_expense_amount_cent',
width: 130,
align: 'right',
render: value => formatMoney(value),
},
{
title: '已打款',
dataIndex: 'disbursement_paid_amount_cent',
width: 130,
align: 'right',
render: value => formatMoney(value),
},
]
function buildDetailColumns(onOpenOrder: (orderNo: string) => void): TableColumnsType<FinanceDetail> {
return [
{
title: '订单',
dataIndex: 'order_no',
width: 200,
fixed: 'left',
render: (_, row) => (
<div className="admin-stack">
<button type="button" className="admin-order-link" onClick={() => onOpenOrder(row.order_no)}>
{row.order_no}
</button>
<StatusTag meta={readStatusMeta(orderStatusMap, row.order_status)} />
<span>结算 {formatText(row.settlement_status)}</span>
</div>
),
},
{
title: '用户',
key: 'users',
width: 170,
render: (_, row) => (
<div className="admin-stack">
<span>租客 {formatText(row.renter_phone)}</span>
<span>号主 {formatText(row.owner_phone)}</span>
</div>
),
},
{
title: '订单金额',
key: 'order_amount',
width: 140,
align: 'right',
render: (_, row) => (
<div className="admin-amount">
<strong>{formatMoney(row.order_rent_amount_cent)}</strong>
<span>押金 {formatMoney(row.order_deposit_amount_cent)}</span>
</div>
),
},
{
title: '结账',
key: 'checkout',
width: 160,
align: 'right',
render: (_, row) => (
<div className="admin-amount">
<span>租金 {formatMoney(row.checkout_rent_amount_cent)}</span>
<span>号主 {formatMoney(row.checkout_owner_income_cent)}</span>
<span>平台费 {formatMoney(row.checkout_platform_fee_cent)}</span>
</div>
),
},
{
title: '支付 / 退款',
key: 'payment',
width: 160,
align: 'right',
render: (_, row) => (
<div className="admin-amount">
<strong>{formatMoney(row.paid_amount_cent)}</strong>
<span>退款 {formatMoney(row.refunded_amount_cent)}</span>
<span>渠道净额 {formatMoney(row.channel_net_amount_cent)}</span>
</div>
),
},
{
title: '财务状态',
dataIndex: 'finance_status',
width: 130,
render: value => <StatusTag meta={readStatusMeta(financeStatusMap, value)} />,
},
{
title: '结算时间',
dataIndex: 'settled_at',
width: 170,
render: value => formatDateTime(value),
},
{
title: '操作',
key: 'actions',
width: 100,
fixed: 'right',
render: (_, row) => (
<Button type="link" size="small" onClick={() => onOpenOrder(row.order_no)}>
查看订单
</Button>
),
},
]
}
export function AdminFinancePage() {
const navigate = useNavigate()
const [range, setRange] = useState<[Dayjs, Dayjs]>([
dayjs().subtract(6, 'day'),
dayjs(),
])
const [keyword, setKeyword] = useState('')
const [appliedKeyword, setAppliedKeyword] = useState('')
const [dateType, setDateType] = useState<DateType>('settled')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const startDate = range[0].format('YYYY-MM-DD')
const endDate = range[1].format('YYYY-MM-DD')
const dashboard = useQuery({
queryKey: ['finance-dashboard', startDate, endDate],
queryFn: () => fetchFinanceDashboard({ start_date: startDate, end_date: endDate }),
staleTime: 60_000,
})
const details = useQuery({
queryKey: ['finance-details', startDate, endDate, appliedKeyword, dateType, page, pageSize],
queryFn: () =>
fetchFinanceDetails({
start_date: startDate,
end_date: endDate,
order_no: appliedKeyword,
date_type: dateType,
page,
page_size: pageSize,
}),
placeholderData: keepPreviousData,
})
const detailColumns = useMemo(
() => buildDetailColumns(orderNo => navigate(`/orders?keyword=${encodeURIComponent(orderNo)}`)),
[navigate]
)
const summary = dashboard.data?.summary
const disbursement = dashboard.data?.disbursement_summary
const pickup = dashboard.data?.pickup_summary
const mohong = dashboard.data?.mohong_summary
const expense = dashboard.data?.operating_expense_summary
const daily = dashboard.data?.daily_items ?? []
const applyDetailFilters = () => {
setAppliedKeyword(keyword.trim())
setPage(1)
}
const resetDetailFilters = () => {
setKeyword('')
setAppliedKeyword('')
setPage(1)
}
const errorText =
dashboard.error instanceof Error ? dashboard.error.message : '请稍后重试或重新登录后台。'
return (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 财务"
title="财务看板"
description="按时间区间核对流水、退款、平台收入、号主结算与出款压力。"
meta={
<Space size={8} wrap>
<Tag color="geekblue">
{startDate} ~ {endDate}
</Tag>
{dashboard.isFetching ? <Tag color="processing">正在更新</Tag> : null}
</Space>
}
extra={
<Space wrap>
<DatePicker.RangePicker
value={range}
allowClear={false}
onChange={value => {
if (value?.[0] && value[1]) {
setRange([value[0], value[1]])
setPage(1)
}
}}
/>
<Button
icon={<ReloadOutlined />}
onClick={() => {
void dashboard.refetch()
void details.refetch()
}}
loading={dashboard.isFetching}
>
刷新
</Button>
</Space>
}
/>
{dashboard.isError ? (
<Alert
type="error"
showIcon
className="admin-table-alert"
message="财务数据没有加载出来"
description={errorText}
action={
<Button size="small" onClick={() => void dashboard.refetch()}>
重试
</Button>
}
/>
) : null}
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} xl={6}>
<Card className="admin-kpi-card">
<div className="admin-kpi-card__icon">
<AccountBookOutlined />
</div>
<Statistic title="总流水" value={formatMoney(summary?.total_flow_amount_cent ?? 0)} />
<span className="admin-kpi-card__foot">
支付 {summary?.successful_pay_count ?? 0} 笔 · 退款{' '}
{summary?.successful_refund_count ?? 0} 笔
</span>
</Card>
</Col>
<Col xs={24} sm={12} xl={6}>
<Card className="admin-kpi-card">
<div className="admin-kpi-card__icon">
<WalletOutlined />
</div>
<Statistic title="总退款" value={formatMoney(summary?.total_refund_amount_cent ?? 0)} />
<span className="admin-kpi-card__foot">
待退款 {formatMoney(summary?.pending_refund_amount_cent ?? 0)} ·{' '}
{summary?.pending_refund_count ?? 0} 笔
</span>
</Card>
</Col>
<Col xs={24} sm={12} xl={6}>
<Card className="admin-kpi-card admin-kpi-card--accent">
<div className="admin-kpi-card__icon">
<RiseOutlined />
</div>
<Statistic
title="平台收入"
value={formatMoney(summary?.platform_income_amount_cent ?? 0)}
/>
<span className="admin-kpi-card__foot">
订单 {formatMoney(summary?.normal_order_profit_amount_cent ?? 0)} · 提号{' '}
{formatMoney(summary?.pickup_profit_amount_cent ?? 0)}
</span>
</Card>
</Col>
<Col xs={24} sm={12} xl={6}>
<Card className="admin-kpi-card">
<div className="admin-kpi-card__icon">
<BankOutlined />
</div>
<Statistic
title="号主应得"
value={formatMoney(summary?.owner_should_income_amount_cent ?? 0)}
/>
<span className="admin-kpi-card__foot">
钱包入账 {formatMoney(summary?.owner_wallet_income_amount_cent ?? 0)}
</span>
</Card>
</Col>
<Col xs={24} sm={12} xl={6}>
<Card className="admin-kpi-card">
<div className="admin-kpi-card__icon">
<WalletOutlined />
</div>
<Statistic
title="渠道净额"
value={formatMoney(summary?.channel_net_amount_cent ?? 0)}
/>
<span className="admin-kpi-card__foot">
已结算订单 {summary?.settled_order_count ?? 0} 笔
</span>
</Card>
</Col>
<Col xs={24} sm={12} xl={6}>
<Card className="admin-kpi-card">
<div className="admin-kpi-card__icon">
<WarningOutlined />
</div>
<Statistic title="资金异常" value={summary?.financial_exception_count ?? 0} />
<span className="admin-kpi-card__foot">
结算差异 {formatMoney(summary?.settlement_diff_amount_cent ?? 0)}
</span>
</Card>
</Col>
<Col xs={24} sm={12} xl={6}>
<Card className="admin-kpi-card">
<div className="admin-kpi-card__icon">
<BankOutlined />
</div>
<Statistic
title="待打款"
value={formatMoney(disbursement?.pending_payment_amount_cent ?? 0)}
/>
<span className="admin-kpi-card__foot">
{disbursement?.pending_payment_count ?? 0} 笔 · 已打款{' '}
{formatMoney(disbursement?.paid_amount_cent ?? 0)}
</span>
</Card>
</Col>
<Col xs={24} sm={12} xl={6}>
<Card className="admin-kpi-card">
<div className="admin-kpi-card__icon">
<WarningOutlined />
</div>
<Statistic
title="提现待审"
value={formatMoney(disbursement?.withdrawal_review_amount_cent ?? 0)}
/>
<span className="admin-kpi-card__foot">
{disbursement?.withdrawal_review_count ?? 0} 笔 · 待打款{' '}
{formatMoney(disbursement?.withdrawal_pending_amount_cent ?? 0)}
</span>
</Card>
</Col>
</Row>
<Row gutter={[16, 16]} className="admin-finance-mini">
<Col xs={24} sm={12} xl={6}>
<Card className="admin-pending-card">
<div>
<span className="admin-pending-card__label">待结算订单</span>
<strong>{summary?.pending_settle_order_count ?? 0}</strong>
</div>
<span>待线下结算 {formatMoney(summary?.offline_settlement_pending_amount_cent ?? 0)}</span>
</Card>
</Col>
<Col xs={24} sm={12} xl={6}>
<Card className="admin-pending-card">
<div>
<span className="admin-pending-card__label">线下提号</span>
<strong>{pickup?.completed_count ?? 0}</strong>
</div>
<span>
利润 {formatMoney(pickup?.profit_amount_cent ?? 0)} · 待结{' '}
{formatMoney(pickup?.pending_amount_cent ?? 0)}
</span>
</Card>
</Col>
<Col xs={24} sm={12} xl={6}>
<Card className="admin-pending-card">
<div>
<span className="admin-pending-card__label">撞车商城净额</span>
<strong>{formatMoney(mohong?.net_amount_cent ?? 0)}</strong>
</div>
<span>
流水 {formatMoney(mohong?.flow_amount_cent ?? 0)} · 退款{' '}
{formatMoney(mohong?.refund_amount_cent ?? 0)}
</span>
</Card>
</Col>
<Col xs={24} sm={12} xl={6}>
<Card className="admin-pending-card">
<div>
<span className="admin-pending-card__label">运营支出</span>
<strong>{formatMoney(expense?.amount_cent ?? 0)}</strong>
</div>
<span>{expense?.count ?? 0} 笔已登记支出</span>
</Card>
</Col>
</Row>
<DataTable<FinanceDaily>
className="admin-finance-daily"
title={<span>每日趋势</span>}
extra={<Typography.Text type="secondary">数据更新于 {formatDateTime(dashboard.data?.generated_at)}</Typography.Text>}
rowKey="date"
columns={dailyColumns}
dataSource={daily}
loading={dashboard.isFetching}
emptyDescription="所选区间没有财务数据"
/>
<FilterBar
onSearch={applyDetailFilters}
onReset={appliedKeyword ? resetDetailFilters : undefined}
loading={details.isFetching}
extra={
<Select
value={dateType}
style={{ width: 160 }}
options={dateTypeOptions}
onChange={value => {
setDateType(value)
setPage(1)
}}
/>
}
>
<FilterField label="订单号" width={280}>
<Input
allowClear
value={keyword}
placeholder="按订单号精确查询"
onChange={event => setKeyword(event.target.value)}
/>
</FilterField>
</FilterBar>
<DataTable<FinanceDetail>
title={<span>财务明细</span>}
extra={
<Typography.Text type="secondary">
{details.data ? `共 ${details.data.total} 条` : '加载中'}
</Typography.Text>
}
rowKey="order_id"
columns={detailColumns}
dataSource={details.data?.items ?? []}
loading={details.isFetching}
total={details.data?.total ?? 0}
page={details.data?.page ?? page}
pageSize={details.data?.page_size ?? pageSize}
onPageChange={(nextPage, nextPageSize) => {
setPage(nextPageSize !== pageSize ? 1 : nextPage)
setPageSize(nextPageSize)
}}
emptyDescription="所选区间没有财务明细"
/>
</div>
)
}
+34
View File
@@ -20,6 +20,8 @@ import type {
AdminWithdrawalPage,
AdminWithdrawalQuery,
DashboardData,
FinanceDashboard,
FinanceDetailPage,
} from '@/features/admin/types'
export async function fetchAdminCaptcha() {
@@ -311,3 +313,35 @@ export async function arbitrateAdminDispute(
)
return data.data
}
export interface FinanceRangeQuery {
start_date?: string
end_date?: string
}
export async function fetchFinanceDashboard(range: FinanceRangeQuery) {
const { data } = await adminClient.get<ApiResponse<FinanceDashboard>>('/admin/finance/dashboard', {
params: compactParams(range),
})
return data.data
}
export async function fetchFinanceDetails(
query: FinanceRangeQuery & {
order_no?: string
date_type?: 'created' | 'settled' | 'offline_settled'
page?: number
page_size?: number
}
) {
const { data } = await adminClient.get<ApiResponse<Partial<FinanceDetailPage>>>(
'/admin/finance/details',
{ 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,
}
}
+8
View File
@@ -189,3 +189,11 @@ export const withdrawalStatusMap: Record<string, StatusMeta> = {
}
export const withdrawalStatusOptions = toOptions(withdrawalStatusMap)
export const financeStatusMap: Record<string, StatusMeta> = {
normal: { label: '正常', tone: 'success' },
refund_pending: { label: '退款中', tone: 'blue' },
refund_failed: { label: '退款失败', tone: 'red' },
settlement_diff: { label: '结算差异', tone: 'orange' },
offline_settlement_pending: { label: '待线下结算', tone: 'gold' },
}
+129
View File
@@ -404,3 +404,132 @@ export interface AdminDisputePage {
page: number
page_size: number
}
export interface FinanceSummary {
total_flow_amount_cent: number
total_refund_amount_cent: number
pending_refund_amount_cent: number
channel_net_amount_cent: number
platform_income_amount_cent: number
normal_order_profit_amount_cent: number
pickup_profit_amount_cent: number
owner_should_income_amount_cent: number
owner_wallet_income_amount_cent: number
owner_effective_settlement_amount_cent: number
offline_settlement_amount_cent: number
offline_settlement_pending_amount_cent: number
settlement_diff_amount_cent: number
estimated_income_amount_cent: number
successful_pay_count: number
successful_refund_count: number
pending_refund_count: number
settled_order_count: number
pending_settle_order_count: number
offline_settlement_pending_count: number
financial_exception_count: number
}
export interface FinanceDaily {
date: string
total_flow_amount_cent: number
total_refund_amount_cent: number
platform_income_amount_cent: number
channel_net_amount_cent: number
owner_should_income_amount_cent: number
settled_order_count: number
paid_order_count: number
operating_expense_amount_cent: number
disbursement_paid_amount_cent: number
}
export interface FinancePickupSummary {
settled_amount_cent: number
profit_amount_cent: number
completed_count: number
in_progress_count: number
pending_amount_cent: number
pending_count: number
}
export interface FinanceMohongSummary {
flow_amount_cent: number
refund_amount_cent: number
net_amount_cent: number
paid_order_count: number
}
export interface FinanceDisbursementSummary {
paid_amount_cent: number
paid_count: number
pending_payment_amount_cent: number
pending_payment_count: number
withdrawal_amount_cent: number
withdrawal_fee_amount_cent: number
withdrawal_paid_amount_cent: number
withdrawal_paid_count: number
withdrawal_pending_amount_cent: number
withdrawal_pending_count: number
withdrawal_review_amount_cent: number
withdrawal_review_count: number
manual_paid_amount_cent: number
manual_paid_count: number
}
export interface FinanceOperatingExpenseSummary {
amount_cent: number
count: number
}
export interface FinanceDashboard {
summary: FinanceSummary
daily_items: FinanceDaily[]
pickup_summary: FinancePickupSummary
mohong_summary: FinanceMohongSummary
disbursement_summary: FinanceDisbursementSummary
operating_expense_summary: FinanceOperatingExpenseSummary
generated_at: string
}
export interface FinanceDetail {
order_id: number
order_no: string
order_status: string
settlement_status: string
refund_status: string
handoff_mode: string
settlement_mode: string
offline_settlement_status: string
offline_settlement_amount_cent: number
renter_id: number
renter_phone: string
renter_nickname: string
owner_id: number
owner_phone: string
owner_nickname: string
order_rent_amount_cent: number
order_deposit_amount_cent: number
checkout_rent_amount_cent: number
checkout_renter_refund_cent: number
checkout_owner_income_cent: number
checkout_platform_fee_cent: number
owner_wallet_income_amount_cent: number
owner_effective_settlement_amount_cent: number
paid_amount_cent: number
refunded_amount_cent: number
refunding_amount_cent: number
failed_refund_amount_cent: number
channel_net_amount_cent: number
platform_net_amount_cent: number
settlement_diff_amount_cent: number
finance_status: string
created_at: string
settled_at?: string | null
offline_settled_at?: string | null
}
export interface FinanceDetailPage {
items: FinanceDetail[]
total: number
page: number
page_size: number
}