281 lines
8.6 KiB
TypeScript
281 lines
8.6 KiB
TypeScript
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>
|
|
)
|
|
}
|