feat(admin): 新增资金出款与运营开支管理

This commit is contained in:
yml2213
2026-10-30 13:40:56 +08:00
parent 4554f1d525
commit dd1d00227c
10 changed files with 1899 additions and 4 deletions
@@ -0,0 +1,815 @@
import { useMemo, useState } from 'react'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import {
Alert,
App,
Button,
Card,
Col,
DatePicker,
Form,
Input,
InputNumber,
Modal,
Row,
Select,
Space,
Tag,
Typography,
type TableColumnsType,
} from 'antd'
import {
PlusOutlined,
ReloadOutlined,
StopOutlined,
} from '@ant-design/icons'
import dayjs, { type Dayjs } from 'dayjs'
import {
createManualDisbursement,
fetchFinanceDisbursements,
voidManualDisbursement,
} from '@/features/admin/api'
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
import { DataTable } from '@/features/admin/components/DataTable'
import { DetailDrawer, type DetailSection } from '@/features/admin/components/DetailDrawer'
import { FilterBar, FilterField } from '@/features/admin/components/FilterBar'
import { StatusTag } from '@/features/admin/components/StatusTag'
import {
financeDisbursementSourceMap,
financeDisbursementSourceOptions,
financeDisbursementStatusMap,
financeDisbursementStatusOptions,
manualDisbursementCategoryMap,
manualDisbursementCategoryOptions,
readStatusMeta,
} from '@/features/admin/labels'
import type {
FinanceDisbursementItem,
FinanceDisbursementQuery,
ManualDisbursementPayload,
} from '@/features/admin/types'
import { useAdminSessionStore } from '@/stores/adminSession'
import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
interface DisbursementFilters {
date_type: 'created' | 'paid'
range: [Dayjs, Dayjs]
source_type: string
status: string
keyword: string
}
interface ManualFormValues {
category: string
custom_category_name?: string
payee_name: string
amount_yuan: number
paid_at: Dayjs
remark: string
voucher_url?: string
}
function defaultFilters(): DisbursementFilters {
return {
date_type: 'created',
range: [dayjs().subtract(29, 'day').startOf('day'), dayjs().endOf('day')],
source_type: '',
status: '',
keyword: '',
}
}
function toQuery(
filters: DisbursementFilters,
page: number,
pageSize: number
): FinanceDisbursementQuery {
return {
page,
page_size: pageSize,
date_type: filters.date_type,
start_date: filters.range[0].format('YYYY-MM-DD'),
end_date: filters.range[1].format('YYYY-MM-DD'),
source_type: filters.source_type,
status: filters.status,
keyword: filters.keyword.trim(),
}
}
function sourceLabel(item: FinanceDisbursementItem) {
return financeDisbursementSourceMap[item.source_type] ?? item.source_type
}
function categoryLabel(item: FinanceDisbursementItem) {
if (item.source_type !== 'manual_offline') {
return sourceLabel(item)
}
if (item.category === 'custom' && item.custom_category_name) {
return item.custom_category_name
}
if (!item.category) return sourceLabel(item)
return manualDisbursementCategoryMap[item.category] ?? item.category
}
function buildColumns(
onOpen: (item: FinanceDisbursementItem) => void,
onVoid: (item: FinanceDisbursementItem) => void,
canManage: boolean
): TableColumnsType<FinanceDisbursementItem> {
return [
{
title: '出款单',
dataIndex: 'business_no',
width: 230,
fixed: 'left',
render: (_, row) => (
<div className="admin-stack">
<button type="button" className="admin-order-link" onClick={() => onOpen(row)}>
{row.business_no}
</button>
<span>
{sourceLabel(row)}
{row.source_type === 'manual_offline' ? ` · ${categoryLabel(row)}` : ''}
</span>
<span className="admin-mono">{formatDateTime(row.business_created_at)}</span>
</div>
),
},
{
title: '收款方',
key: 'payee',
width: 230,
render: (_, row) => (
<div className="admin-stack">
<strong>{formatText(row.payee_name)}</strong>
<span>{formatText(row.payee_phone)}</span>
<span className="admin-mono">
{row.account_type === 'bank'
? `${formatText(row.bank_name)} · ${formatText(row.account_no)}`
: formatText(row.account_name || row.account_no)}
</span>
</div>
),
},
{
title: '金额',
key: 'amount',
width: 160,
align: 'right',
render: (_, row) => (
<div className="admin-amount">
<strong>{formatMoney(row.amount_cent)}</strong>
<span>手续费 {formatMoney(row.fee_cent)}</span>
<span className="admin-amount__accent">实付 {formatMoney(row.actual_amount_cent)}</span>
</div>
),
},
{
title: '状态',
dataIndex: 'status',
width: 110,
render: value => <StatusTag meta={readStatusMeta(financeDisbursementStatusMap, value)} />,
},
{
title: '打款 / 操作人',
key: 'paid',
width: 190,
render: (_, row) => (
<div className="admin-stack">
<span>打款 {formatDateTime(row.paid_at)}</span>
<span>操作 {row.operator_name || '—'}</span>
<span>{formatDateTime(row.business_created_at)}</span>
</div>
),
},
{
title: '备注',
dataIndex: 'remark',
width: 240,
render: value => (
<Typography.Text type="secondary" ellipsis={{ tooltip: formatText(value) }}>
<span className="admin-audit-summary">{formatText(value)}</span>
</Typography.Text>
),
},
{
title: '操作',
key: 'actions',
width: 150,
fixed: 'right',
render: (_, row) => (
<Space size={0}>
<Button type="link" size="small" onClick={() => onOpen(row)}>
详情
</Button>
{canManage && row.source_type === 'manual_offline' && row.status === 'paid' ? (
<Button
type="link"
size="small"
danger
icon={<StopOutlined />}
onClick={() => onVoid(row)}
>
作废
</Button>
) : null}
</Space>
),
},
]
}
function buildSections(item: FinanceDisbursementItem): DetailSection[] {
return [
{
key: 'base',
title: '出款信息',
fields: [
{
key: 'no',
label: '出款单号',
value: <span className="admin-mono">{item.business_no}</span>,
},
{
key: 'source',
label: '出款类型',
value: (
<Space size={4} wrap>
<Tag>{sourceLabel(item)}</Tag>
{item.source_type === 'manual_offline' ? <Tag>{categoryLabel(item)}</Tag> : null}
</Space>
),
},
{
key: 'status',
label: '状态',
value: <StatusTag meta={readStatusMeta(financeDisbursementStatusMap, item.status)} />,
},
{
key: 'raw_status',
label: '原始状态',
value: <span className="admin-mono">{formatText(item.raw_status)}</span>,
},
{
key: 'business_time',
label: '业务时间',
value: formatDateTime(item.business_created_at),
},
{
key: 'paid_time',
label: '打款时间',
value: formatDateTime(item.paid_at),
},
],
},
{
key: 'payee',
title: '收款信息',
fields: [
{ key: 'name', label: '收款方', value: formatText(item.payee_name) },
{ key: 'phone', label: '手机号', value: formatText(item.payee_phone) },
{
key: 'account',
label: '收款账户',
value: (
<div className="admin-stack">
<span>{formatText(item.account_type)}</span>
<span className="admin-mono">{formatText(item.account_name)}</span>
<span className="admin-mono">{formatText(item.account_no)}</span>
</div>
),
},
{ key: 'bank', label: '开户行', value: formatText(item.bank_name) },
{ key: 'channel', label: '来源渠道', value: formatText(item.source_channel) },
{ key: 'uploader', label: '上传人', value: formatText(item.uploader_name) },
],
},
{
key: 'amount',
title: '金额',
fields: [
{ key: 'amount', label: '申请金额', value: <span className="admin-money">{formatMoney(item.amount_cent)}</span> },
{ key: 'fee', label: '手续费', value: <span className="admin-money">{formatMoney(item.fee_cent)}</span> },
{
key: 'actual',
label: '实际出款',
value: <span className="admin-money">{formatMoney(item.actual_amount_cent)}</span>,
},
{ key: 'operator', label: '操作人', value: formatText(item.operator_name) },
],
},
{
key: 'remark',
title: '备注与凭证',
fields: [
{ key: 'remark', label: '备注', span: 2, value: formatText(item.remark) },
{
key: 'voucher',
label: '凭证',
span: 2,
value: item.voucher_url ? (
<a href={item.voucher_url} target="_blank" rel="noreferrer" className="admin-mono">
{item.voucher_url}
</a>
) : (
'未上传凭证'
),
},
...(item.status === 'voided'
? [
{ key: 'voided_at', label: '作废时间', value: formatDateTime(item.voided_at) },
{ key: 'voided_by', label: '作废人', value: formatText(item.voided_by_name) },
{ key: 'void_reason', label: '作废原因', span: 2 as const, value: formatText(item.void_reason) },
]
: []),
],
},
]
}
export function AdminFinanceDisbursementsPage() {
const { message, modal } = App.useApp()
const [form] = Form.useForm<ManualFormValues>()
const [draft, setDraft] = useState<DisbursementFilters>(defaultFilters)
const [applied, setApplied] = useState<DisbursementFilters>(defaultFilters)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [selected, setSelected] = useState<FinanceDisbursementItem | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [creating, setCreating] = useState(false)
const [voiding, setVoiding] = useState<FinanceDisbursementItem | null>(null)
const [voidReason, setVoidReason] = useState('')
const [voidingSaving, setVoidingSaving] = useState(false)
const permissions = useAdminSessionStore(state => state.admin?.permissions)
const canView = Boolean(permissions?.includes('*') || permissions?.includes('wallet:view'))
const canManageManual = Boolean(
permissions?.includes('*') || permissions?.includes('finance:manual_disbursement')
)
const category = Form.useWatch('category', form)
const query = useMemo(() => toQuery(applied, page, pageSize), [applied, page, pageSize])
const { data, isPending, isFetching, isError, error, refetch } = useQuery({
queryKey: ['admin-finance-disbursements', query],
queryFn: () => fetchFinanceDisbursements(query),
placeholderData: keepPreviousData,
enabled: canView,
})
const summary = data?.summary
const errorText = error instanceof Error ? error.message : '请稍后重试或重新登录后台。'
const defaultRange = defaultFilters().range
const hasActiveFilters = Boolean(
draft.source_type ||
draft.status ||
draft.keyword ||
draft.date_type !== 'created' ||
draft.range[0].format('YYYY-MM-DD') !== defaultRange[0].format('YYYY-MM-DD') ||
draft.range[1].format('YYYY-MM-DD') !== defaultRange[1].format('YYYY-MM-DD')
)
const applyFilters = () => {
setApplied({ ...draft, range: [draft.range[0], draft.range[1]] })
setPage(1)
}
const resetFilters = () => {
const next = defaultFilters()
setDraft(next)
setApplied(next)
setPage(1)
}
const openDetail = (item: FinanceDisbursementItem) => {
setSelected(item)
setDetailOpen(true)
}
const openCreate = () => {
form.resetFields()
form.setFieldsValue({
category: '',
custom_category_name: '',
payee_name: '',
amount_yuan: 0,
paid_at: dayjs(),
remark: '',
voucher_url: '',
})
setCreateOpen(true)
}
const submitCreate = async () => {
if (!canManageManual) return
let values: ManualFormValues
try {
values = await form.validateFields()
} catch {
return
}
const payload: ManualDisbursementPayload = {
category: values.category,
custom_category_name:
values.category === 'custom' ? values.custom_category_name?.trim() : undefined,
payee_name: values.payee_name.trim(),
amount_cent: Math.round(Number(values.amount_yuan) * 100),
paid_at: values.paid_at.toISOString(),
remark: values.remark.trim(),
voucher_url: values.voucher_url?.trim() || undefined,
}
modal.confirm({
title: '确认记一笔线下出款?',
content: '记账后会立即计入财务出款统计,并写入审计日志;如填错需再作废。',
okText: '确认记账',
cancelText: '再检查一下',
onOk: async () => {
setCreating(true)
try {
await createManualDisbursement(payload)
message.success('线下出款已记账')
setCreateOpen(false)
await refetch()
} catch (err) {
message.error(err instanceof Error ? err.message : '记账失败,请稍后重试')
} finally {
setCreating(false)
}
},
})
}
const submitVoid = async () => {
if (!voiding) return
if (!voidReason.trim()) {
message.warning('请填写作废原因')
return
}
setVoidingSaving(true)
try {
await voidManualDisbursement(voiding.source_id, voidReason.trim())
message.success('线下出款已作废')
setVoiding(null)
setVoidReason('')
if (selected?.source_type === 'manual_offline' && selected.source_id === voiding.source_id) {
setDetailOpen(false)
}
await refetch()
} catch (err) {
message.error(err instanceof Error ? err.message : '作废失败,请稍后重试')
} finally {
setVoidingSaving(false)
}
}
const columns = useMemo(
() =>
buildColumns(
openDetail,
item => {
setVoiding(item)
setVoidReason('')
},
canManageManual
),
[canManageManual]
)
const detailFooter = selected ? (
<Space>
<Button onClick={() => setDetailOpen(false)}>关闭</Button>
{canManageManual &&
selected.source_type === 'manual_offline' &&
selected.status === 'paid' ? (
<Button
danger
icon={<StopOutlined />}
onClick={() => {
setVoiding(selected)
setVoidReason('')
}}
>
作废出款
</Button>
) : null}
</Space>
) : null
return (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 财务"
title="资金出款"
description="统一查看平台代管、线下提号、钱包提现与线下出款,并补记或作废手工出款。"
meta={
<Space size={8} wrap>
<Tag color="geekblue">共 {summary?.record_count ?? 0} 笔</Tag>
<Tag color="success">已打款 {summary?.paid_count ?? 0} 笔</Tag>
{isFetching && !isPending ? <Tag color="processing">正在更新</Tag> : null}
</Space>
}
extra={
<Space>
{canManageManual ? (
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
新增线下出款
</Button>
) : null}
<Button
icon={<ReloadOutlined />}
onClick={() => void refetch()}
loading={isFetching}
disabled={!canView}
>
刷新数据
</Button>
</Space>
}
/>
{!canView ? (
<Alert
type="warning"
showIcon
className="admin-table-alert"
message="当前账号没有资金出款查看权限"
description="需要 wallet:view 权限才能查看出款明细,补记线下出款还需要 finance:manual_disbursement。"
/>
) : null}
<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>{formatMoney(summary?.paid_amount_cent ?? 0)}</strong>
</div>
<span>{summary?.paid_count ?? 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(summary?.payment_pending_amount_cent ?? 0)}</strong>
</div>
<span>{summary?.payment_pending_count ?? 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>{summary?.review_pending_count ?? 0}</strong>
</div>
<span>{formatMoney(summary?.review_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(summary?.withdrawal_fee_amount_cent ?? 0)}</strong>
</div>
<span>当前筛选区间内已打款提现</span>
</Card>
</Col>
</Row>
<FilterBar onSearch={applyFilters} onReset={hasActiveFilters ? resetFilters : undefined} loading={isFetching}>
<FilterField label="日期类型" width={150}>
<Select
value={draft.date_type}
options={[
{ value: 'created', label: '按业务时间' },
{ value: 'paid', label: '按打款时间' },
]}
onChange={value => setDraft(current => ({ ...current, date_type: value }))}
/>
</FilterField>
<FilterField label="日期范围" width={260}>
<DatePicker.RangePicker
value={draft.range}
allowClear={false}
onChange={value => {
if (value?.[0] && value?.[1]) {
setDraft(current => ({ ...current, range: [value[0]!, value[1]!] }))
}
}}
/>
</FilterField>
<FilterField label="出款类型" width={160}>
<Select
allowClear
value={draft.source_type || undefined}
placeholder="全部类型"
options={financeDisbursementSourceOptions}
onChange={value => setDraft(current => ({ ...current, source_type: value ?? '' }))}
/>
</FilterField>
<FilterField label="状态" width={150}>
<Select
allowClear
value={draft.status || undefined}
placeholder="全部状态"
options={financeDisbursementStatusOptions}
onChange={value => setDraft(current => ({ ...current, status: value ?? '' }))}
/>
</FilterField>
<FilterField label="关键词" width={220}>
<Input
allowClear
value={draft.keyword}
placeholder="单号 / 收款方 / 账户"
onChange={event => setDraft(current => ({ ...current, keyword: event.target.value }))}
/>
</FilterField>
</FilterBar>
{isError ? (
<Alert
type="error"
showIcon
className="admin-table-alert"
message="资金出款没有加载出来"
description={errorText}
action={
<Button size="small" onClick={() => void refetch()}>
重试
</Button>
}
/>
) : null}
<DataTable<FinanceDisbursementItem>
title={<span>出款明细</span>}
extra={
<Typography.Text type="secondary">
{data ? `第 ${data.page} 页 · 当前 ${data.items.length} 条` : '加载中'}
</Typography.Text>
}
rowKey={row => `${row.source_type}-${row.source_id}`}
columns={columns}
dataSource={data?.items ?? []}
loading={isFetching}
total={data?.total ?? 0}
page={data?.page ?? page}
pageSize={data?.page_size ?? pageSize}
onPageChange={(nextPage, nextPageSize) => {
setPage(nextPageSize !== pageSize ? 1 : nextPage)
setPageSize(nextPageSize)
}}
onRowClick={openDetail}
emptyDescription="当前筛选区间内没有出款记录"
/>
<DetailDrawer
open={detailOpen}
onClose={() => setDetailOpen(false)}
title={selected ? `出款详情 · ${selected.business_no}` : '出款详情'}
subtitle={
selected
? `${sourceLabel(selected)} · ${formatDateTime(selected.business_created_at)}`
: undefined
}
tags={
selected ? (
<StatusTag meta={readStatusMeta(financeDisbursementStatusMap, selected.status)} />
) : undefined
}
sections={selected ? buildSections(selected) : []}
footer={detailFooter}
width={820}
emptyDescription="没有可展示的出款详情"
/>
<Modal
open={createOpen}
title="新增线下出款记账"
width={720}
onCancel={() => {
if (!creating) setCreateOpen(false)
}}
maskClosable={!creating}
closable={!creating}
destroyOnHidden
footer={
<Space>
<Button onClick={() => setCreateOpen(false)} disabled={creating}>
取消
</Button>
<Button type="primary" loading={creating} onClick={() => void submitCreate()}>
确认记账
</Button>
</Space>
}
>
<Form<ManualFormValues> form={form} layout="vertical" disabled={creating}>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="category"
label="出款分类"
rules={[{ required: true, message: '请选择出款分类' }]}
>
<Select options={manualDisbursementCategoryOptions} placeholder="请选择" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="payee_name"
label="收款人 / 单位"
rules={[{ required: true, message: '请输入收款方' }]}
>
<Input maxLength={100} placeholder="实际收款方姓名或单位" />
</Form.Item>
</Col>
</Row>
{category === 'custom' ? (
<Form.Item
name="custom_category_name"
label="自定义分类名称"
rules={[{ required: true, message: '请输入自定义分类名称' }]}
>
<Input maxLength={50} placeholder="不超过 50 字" />
</Form.Item>
) : null}
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="amount_yuan"
label="出款金额(元)"
rules={[{ required: true, message: '请输入出款金额' }]}
>
<InputNumber
min={0.01}
precision={2}
step={10}
style={{ width: '100%' }}
placeholder="0.00"
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="paid_at"
label="打款时间"
rules={[{ required: true, message: '请选择打款时间' }]}
>
<DatePicker showTime style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Form.Item
name="remark"
label="出款用途 / 备注"
rules={[{ required: true, message: '请填写出款用途' }]}
>
<Input.TextArea rows={4} maxLength={500} showCount placeholder="详细记录出款原因与对账依据" />
</Form.Item>
<Form.Item name="voucher_url" label="打款凭证地址(可选)">
<Input placeholder="https://..." />
</Form.Item>
</Form>
</Modal>
<Modal
open={Boolean(voiding)}
title="作废线下出款"
width={520}
onCancel={() => {
if (!voidingSaving) setVoiding(null)
}}
maskClosable={!voidingSaving}
closable={!voidingSaving}
destroyOnHidden
footer={
<Space>
<Button onClick={() => setVoiding(null)} disabled={voidingSaving}>
取消
</Button>
<Button danger loading={voidingSaving} onClick={() => void submitVoid()}>
确认作废
</Button>
</Space>
}
>
<Alert
type="warning"
showIcon
className="admin-table-alert"
message={`将作废出款单 ${voiding?.business_no ?? ''}`}
description="作废后会从财务出款统计中排除,并记录审计日志;该操作不可恢复。"
/>
<Input.TextArea
rows={4}
maxLength={200}
showCount
value={voidReason}
placeholder="请填写作废原因"
onChange={event => setVoidReason(event.target.value)}
/>
</Modal>
</div>
)
}
@@ -0,0 +1,700 @@
import { useMemo, useState } from 'react'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import {
Alert,
App,
AutoComplete,
Button,
Card,
Col,
DatePicker,
Form,
Input,
InputNumber,
Modal,
Row,
Select,
Space,
Tag,
Typography,
type TableColumnsType,
} from 'antd'
import { PlusOutlined, ReloadOutlined, StopOutlined } from '@ant-design/icons'
import dayjs, { type Dayjs } from 'dayjs'
import {
createOperatingExpense,
fetchOperatingExpenses,
voidOperatingExpense,
} from '@/features/admin/api'
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
import { DataTable } from '@/features/admin/components/DataTable'
import { DetailDrawer, type DetailSection } from '@/features/admin/components/DetailDrawer'
import { FilterBar, FilterField } from '@/features/admin/components/FilterBar'
import { StatusTag } from '@/features/admin/components/StatusTag'
import {
operatingExpenseStatusMap,
operatingExpenseStatusOptions,
readStatusMeta,
} from '@/features/admin/labels'
import type {
OperatingExpense,
OperatingExpensePayload,
OperatingExpenseQuery,
} from '@/features/admin/types'
import { useAdminSessionStore } from '@/stores/adminSession'
import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
interface ExpenseFilters {
date_type: 'created' | 'occurred'
range: [Dayjs, Dayjs]
status: string
category: string
keyword: string
}
interface ExpenseFormValues {
category: string
payee_name: string
amount_yuan: number
occurred_at: Dayjs
remark: string
voucher_url?: string
}
const categorySuggestions = [
'服务器宽带',
'日常采购',
'软件授权',
'推广费用',
'物流快递',
'办公租赁',
'员工福利',
'其他',
].map(value => ({ value }))
function defaultFilters(): ExpenseFilters {
return {
date_type: 'created',
range: [dayjs().subtract(29, 'day').startOf('day'), dayjs().endOf('day')],
status: '',
category: '',
keyword: '',
}
}
function toQuery(filters: ExpenseFilters, page: number, pageSize: number): OperatingExpenseQuery {
return {
page,
page_size: pageSize,
date_type: filters.date_type,
start_date: filters.range[0].format('YYYY-MM-DD'),
end_date: filters.range[1].format('YYYY-MM-DD'),
status: filters.status,
category: filters.category.trim(),
keyword: filters.keyword.trim(),
}
}
function buildColumns(
onOpen: (item: OperatingExpense) => void,
onVoid: (item: OperatingExpense) => void,
canManage: boolean
): TableColumnsType<OperatingExpense> {
return [
{
title: '开支单',
dataIndex: 'expense_no',
width: 230,
fixed: 'left',
render: (_, row) => (
<div className="admin-stack">
<button type="button" className="admin-order-link" onClick={() => onOpen(row)}>
{row.expense_no}
</button>
<span>{formatText(row.category)}</span>
<span className="admin-mono">{formatDateTime(row.created_at)}</span>
</div>
),
},
{
title: '收款方',
dataIndex: 'payee_name',
width: 180,
render: value => <strong>{formatText(value)}</strong>,
},
{
title: '开支金额',
dataIndex: 'amount_cent',
width: 150,
align: 'right',
render: value => <span className="admin-money">{formatMoney(value)}</span>,
},
{
title: '实际开支时间',
dataIndex: 'occurred_at',
width: 170,
render: value => formatDateTime(value),
},
{
title: '状态',
dataIndex: 'status',
width: 100,
render: value => <StatusTag meta={readStatusMeta(operatingExpenseStatusMap, value)} />,
},
{
title: '录入人',
dataIndex: 'created_by_name',
width: 130,
render: value => formatText(value),
},
{
title: '用途 / 备注',
dataIndex: 'remark',
width: 280,
render: value => (
<Typography.Text type="secondary" ellipsis={{ tooltip: formatText(value) }}>
<span className="admin-audit-summary">{formatText(value)}</span>
</Typography.Text>
),
},
{
title: '操作',
key: 'actions',
width: 150,
fixed: 'right',
render: (_, row) => (
<Space size={0}>
<Button type="link" size="small" onClick={() => onOpen(row)}>
详情
</Button>
{canManage && row.status === 'paid' ? (
<Button
type="link"
size="small"
danger
icon={<StopOutlined />}
onClick={() => onVoid(row)}
>
作废
</Button>
) : null}
</Space>
),
},
]
}
function buildSections(item: OperatingExpense): DetailSection[] {
return [
{
key: 'base',
title: '开支信息',
fields: [
{
key: 'no',
label: '开支单号',
value: <span className="admin-mono">{item.expense_no}</span>,
},
{ key: 'category', label: '开支类别', value: formatText(item.category) },
{
key: 'status',
label: '状态',
value: <StatusTag meta={readStatusMeta(operatingExpenseStatusMap, item.status)} />,
},
{ key: 'payee', label: '收款方', value: formatText(item.payee_name) },
{
key: 'amount',
label: '开支金额',
value: <span className="admin-money">{formatMoney(item.amount_cent)}</span>,
},
{ key: 'occurred_at', label: '实际开支时间', value: formatDateTime(item.occurred_at) },
{ key: 'created_at', label: '录入时间', value: formatDateTime(item.created_at) },
{ key: 'created_by', label: '录入管理员', value: formatText(item.created_by_name) },
],
},
{
key: 'detail',
title: '用途与凭证',
fields: [
{ key: 'remark', label: '用途 / 备注', span: 2, value: formatText(item.remark) },
{
key: 'voucher',
label: '凭证',
span: 2,
value: item.voucher_url ? (
<a href={item.voucher_url} target="_blank" rel="noreferrer" className="admin-mono">
{item.voucher_url}
</a>
) : (
'未上传凭证'
),
},
...(item.status === 'voided'
? [
{ key: 'voided_at', label: '作废时间', value: formatDateTime(item.voided_at) },
{ key: 'voided_by', label: '作废人', value: formatText(item.voided_by_name) },
{
key: 'void_reason',
label: '作废原因',
span: 2 as const,
value: formatText(item.void_reason),
},
]
: []),
],
},
]
}
export function AdminOperatingExpensesPage() {
const { message, modal } = App.useApp()
const [form] = Form.useForm<ExpenseFormValues>()
const [draft, setDraft] = useState<ExpenseFilters>(defaultFilters)
const [applied, setApplied] = useState<ExpenseFilters>(defaultFilters)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [selected, setSelected] = useState<OperatingExpense | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [creating, setCreating] = useState(false)
const [voiding, setVoiding] = useState<OperatingExpense | null>(null)
const [voidReason, setVoidReason] = useState('')
const [voidingSaving, setVoidingSaving] = useState(false)
const permissions = useAdminSessionStore(state => state.admin?.permissions)
const canManage = Boolean(
permissions?.includes('*') || permissions?.includes('finance:operating_expense')
)
const query = useMemo(() => toQuery(applied, page, pageSize), [applied, page, pageSize])
const { data, isPending, isFetching, isError, error, refetch } = useQuery({
queryKey: ['admin-operating-expenses', query],
queryFn: () => fetchOperatingExpenses(query),
placeholderData: keepPreviousData,
enabled: canManage,
})
const summary = data?.summary
const errorText = error instanceof Error ? error.message : '请稍后重试或重新登录后台。'
const defaultRange = defaultFilters().range
const hasActiveFilters = Boolean(
draft.status ||
draft.category ||
draft.keyword ||
draft.date_type !== 'created' ||
draft.range[0].format('YYYY-MM-DD') !== defaultRange[0].format('YYYY-MM-DD') ||
draft.range[1].format('YYYY-MM-DD') !== defaultRange[1].format('YYYY-MM-DD')
)
const applyFilters = () => {
setApplied({ ...draft, range: [draft.range[0], draft.range[1]] })
setPage(1)
}
const resetFilters = () => {
const next = defaultFilters()
setDraft(next)
setApplied(next)
setPage(1)
}
const openDetail = (item: OperatingExpense) => {
setSelected(item)
setDetailOpen(true)
}
const openCreate = () => {
form.resetFields()
form.setFieldsValue({
category: '',
payee_name: '',
amount_yuan: 0,
occurred_at: dayjs(),
remark: '',
voucher_url: '',
})
setCreateOpen(true)
}
const submitCreate = async () => {
let values: ExpenseFormValues
try {
values = await form.validateFields()
} catch {
return
}
const payload: OperatingExpensePayload = {
category: values.category.trim(),
payee_name: values.payee_name.trim(),
amount_cent: Math.round(Number(values.amount_yuan) * 100),
occurred_at: values.occurred_at.toISOString(),
remark: values.remark.trim(),
voucher_url: values.voucher_url?.trim() || undefined,
}
modal.confirm({
title: '确认登记运营开支?',
content: '登记后会立即计入运营开支统计,并写入审计日志;如填错需再作废。',
okText: '确认登记',
cancelText: '再检查一下',
onOk: async () => {
setCreating(true)
try {
await createOperatingExpense(payload)
message.success('运营开支已登记')
setCreateOpen(false)
await refetch()
} catch (err) {
message.error(err instanceof Error ? err.message : '登记失败,请稍后重试')
} finally {
setCreating(false)
}
},
})
}
const submitVoid = async () => {
if (!voiding) return
if (!voidReason.trim()) {
message.warning('请填写作废原因')
return
}
setVoidingSaving(true)
try {
await voidOperatingExpense(voiding.id, voidReason.trim())
message.success('运营开支已作废')
setVoiding(null)
setVoidReason('')
if (selected?.id === voiding.id) setDetailOpen(false)
await refetch()
} catch (err) {
message.error(err instanceof Error ? err.message : '作废失败,请稍后重试')
} finally {
setVoidingSaving(false)
}
}
const columns = useMemo(
() =>
buildColumns(
openDetail,
item => {
setVoiding(item)
setVoidReason('')
},
canManage
),
[canManage]
)
const detailFooter = selected ? (
<Space>
<Button onClick={() => setDetailOpen(false)}>关闭</Button>
{canManage && selected.status === 'paid' ? (
<Button
danger
icon={<StopOutlined />}
onClick={() => {
setVoiding(selected)
setVoidReason('')
}}
>
作废开支
</Button>
) : null}
</Space>
) : null
return (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 财务"
title="运营开支"
description="独立记录服务器、采购、推广与日常运营支出,按发生时间汇总进财务看板。"
meta={
<Space size={8} wrap>
<Tag color="geekblue">共 {summary?.record_count ?? 0} 笔</Tag>
<Tag color="gold">有效 {summary?.paid_count ?? 0} 笔</Tag>
{isFetching && !isPending ? <Tag color="processing">正在更新</Tag> : null}
</Space>
}
extra={
<Space>
{canManage ? (
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
新增运营开支
</Button>
) : null}
<Button
icon={<ReloadOutlined />}
onClick={() => void refetch()}
loading={isFetching}
disabled={!canManage}
>
刷新数据
</Button>
</Space>
}
/>
{!canManage ? (
<Alert
type="warning"
showIcon
className="admin-table-alert"
message="当前账号没有运营开支权限"
description="需要 finance:operating_expense 权限才能查看、登记或作废运营开支。"
/>
) : null}
<Row gutter={[16, 16]} className="admin-finance-mini">
<Col xs={24} sm={12} xl={8}>
<Card className="admin-pending-card">
<div>
<span className="admin-pending-card__label">有效开支</span>
<strong>{formatMoney(summary?.paid_amount_cent ?? 0)}</strong>
</div>
<span>{summary?.paid_count ?? 0} 笔已记账</span>
</Card>
</Col>
<Col xs={24} sm={12} xl={8}>
<Card className="admin-pending-card">
<div>
<span className="admin-pending-card__label">当前筛选记录</span>
<strong>{summary?.record_count ?? 0}</strong>
</div>
<span>包含已作废记录</span>
</Card>
</Col>
</Row>
<FilterBar onSearch={applyFilters} onReset={hasActiveFilters ? resetFilters : undefined} loading={isFetching}>
<FilterField label="日期类型" width={160}>
<Select
value={draft.date_type}
options={[
{ value: 'created', label: '按录入日期' },
{ value: 'occurred', label: '按开支日期' },
]}
onChange={value => setDraft(current => ({ ...current, date_type: value }))}
/>
</FilterField>
<FilterField label="日期范围" width={260}>
<DatePicker.RangePicker
value={draft.range}
allowClear={false}
onChange={value => {
if (value?.[0] && value?.[1]) {
setDraft(current => ({ ...current, range: [value[0]!, value[1]!] }))
}
}}
/>
</FilterField>
<FilterField label="状态" width={140}>
<Select
allowClear
value={draft.status || undefined}
placeholder="全部状态"
options={operatingExpenseStatusOptions}
onChange={value => setDraft(current => ({ ...current, status: value ?? '' }))}
/>
</FilterField>
<FilterField label="开支类别" width={160}>
<AutoComplete
allowClear
value={draft.category || undefined}
options={categorySuggestions}
placeholder="全部类别"
filterOption={(input, option) =>
String(option?.value ?? '').includes(input)
}
onChange={value => setDraft(current => ({ ...current, category: value ?? '' }))}
/>
</FilterField>
<FilterField label="关键词" width={220}>
<Input
allowClear
value={draft.keyword}
placeholder="单号 / 收款方 / 用途"
onChange={event => setDraft(current => ({ ...current, keyword: event.target.value }))}
/>
</FilterField>
</FilterBar>
{isError ? (
<Alert
type="error"
showIcon
className="admin-table-alert"
message="运营开支没有加载出来"
description={errorText}
action={
<Button size="small" onClick={() => void refetch()}>
重试
</Button>
}
/>
) : null}
<DataTable<OperatingExpense>
title={<span>开支明细</span>}
extra={
<Typography.Text type="secondary">
{data ? `第 ${data.page} 页 · 当前 ${data.items.length} 条` : '加载中'}
</Typography.Text>
}
rowKey="id"
columns={columns}
dataSource={data?.items ?? []}
loading={isFetching}
total={data?.total ?? 0}
page={data?.page ?? page}
pageSize={data?.page_size ?? pageSize}
onPageChange={(nextPage, nextPageSize) => {
setPage(nextPageSize !== pageSize ? 1 : nextPage)
setPageSize(nextPageSize)
}}
onRowClick={openDetail}
emptyDescription="当前筛选区间内没有运营开支"
/>
<DetailDrawer
open={detailOpen}
onClose={() => setDetailOpen(false)}
title={selected ? `开支详情 · ${selected.expense_no}` : '开支详情'}
subtitle={selected ? `${selected.category} · ${formatDateTime(selected.occurred_at)}` : undefined}
tags={
selected ? (
<StatusTag meta={readStatusMeta(operatingExpenseStatusMap, selected.status)} />
) : undefined
}
sections={selected ? buildSections(selected) : []}
footer={detailFooter}
width={780}
emptyDescription="没有可展示的开支详情"
/>
<Modal
open={createOpen}
title="新增运营开支"
width={680}
onCancel={() => {
if (!creating) setCreateOpen(false)
}}
maskClosable={!creating}
closable={!creating}
destroyOnHidden
footer={
<Space>
<Button onClick={() => setCreateOpen(false)} disabled={creating}>
取消
</Button>
<Button type="primary" loading={creating} onClick={() => void submitCreate()}>
确认登记
</Button>
</Space>
}
>
<Form<ExpenseFormValues> form={form} layout="vertical" disabled={creating}>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="category"
label="开支类别"
rules={[{ required: true, message: '请输入开支类别' }]}
>
<AutoComplete
options={categorySuggestions}
placeholder="例如:服务器宽带、日常采购"
filterOption={(input, option) => String(option?.value ?? '').includes(input)}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="payee_name"
label="收款方 / 单位"
rules={[{ required: true, message: '请输入收款方' }]}
>
<Input maxLength={100} placeholder="实际收款方或单位名称" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="amount_yuan"
label="开支金额(元)"
rules={[{ required: true, message: '请输入开支金额' }]}
>
<InputNumber
min={0.01}
precision={2}
step={10}
style={{ width: '100%' }}
placeholder="0.00"
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="occurred_at"
label="开支发生时间"
rules={[{ required: true, message: '请选择开支发生时间' }]}
>
<DatePicker showTime style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Form.Item
name="remark"
label="开支用途 / 详细备注"
rules={[{ required: true, message: '请填写开支用途' }]}
>
<Input.TextArea rows={4} maxLength={500} showCount placeholder="详细记录开支用途与对账依据" />
</Form.Item>
<Form.Item name="voucher_url" label="凭证 / 发票地址(可选)">
<Input placeholder="https://..." />
</Form.Item>
</Form>
</Modal>
<Modal
open={Boolean(voiding)}
title="作废运营开支"
width={520}
onCancel={() => {
if (!voidingSaving) setVoiding(null)
}}
maskClosable={!voidingSaving}
closable={!voidingSaving}
destroyOnHidden
footer={
<Space>
<Button onClick={() => setVoiding(null)} disabled={voidingSaving}>
取消
</Button>
<Button danger loading={voidingSaving} onClick={() => void submitVoid()}>
确认作废
</Button>
</Space>
}
>
<Alert
type="warning"
showIcon
className="admin-table-alert"
message={`将作废开支单 ${voiding?.expense_no ?? ''}`}
description="作废后会从运营开支统计中排除,并记录审计日志;该操作不可恢复。"
/>
<Input.TextArea
rows={4}
maxLength={200}
showCount
value={voidReason}
placeholder="请填写作废原因"
onChange={event => setVoidReason(event.target.value)}
/>
</Modal>
</div>
)
}
+79
View File
@@ -33,6 +33,12 @@ import type {
DashboardData,
FinanceDashboard,
FinanceDetailPage,
FinanceDisbursementPage,
FinanceDisbursementQuery,
ManualDisbursementPayload,
OperatingExpensePage,
OperatingExpensePayload,
OperatingExpenseQuery,
} from '@/features/admin/types'
export async function fetchAdminCaptcha() {
@@ -462,3 +468,76 @@ export async function updateAdminDistributionSite(
)
return data.data
}
export async function fetchFinanceDisbursements(query: FinanceDisbursementQuery) {
const { data } = await adminClient.get<ApiResponse<Partial<FinanceDisbursementPage>>>(
'/admin/finance/disbursements',
{ 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,
summary: data.data.summary ?? {
record_count: 0,
paid_amount_cent: 0,
paid_count: 0,
payment_pending_amount_cent: 0,
payment_pending_count: 0,
review_pending_amount_cent: 0,
review_pending_count: 0,
withdrawal_fee_amount_cent: 0,
},
}
}
export async function createManualDisbursement(payload: ManualDisbursementPayload) {
const { data } = await adminClient.post<ApiResponse<unknown>>(
'/admin/finance/manual-disbursements',
payload
)
return data.data
}
export async function voidManualDisbursement(id: number | string, reason: string) {
const { data } = await adminClient.post<ApiResponse<unknown>>(
`/admin/finance/manual-disbursements/${id}/void`,
{ reason }
)
return data.data
}
export async function fetchOperatingExpenses(query: OperatingExpenseQuery) {
const { data } = await adminClient.get<ApiResponse<Partial<OperatingExpensePage>>>(
'/admin/finance/operating-expenses',
{ 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,
summary: data.data.summary ?? {
record_count: 0,
paid_amount_cent: 0,
paid_count: 0,
},
}
}
export async function createOperatingExpense(payload: OperatingExpensePayload) {
const { data } = await adminClient.post<ApiResponse<unknown>>(
'/admin/finance/operating-expenses',
payload
)
return data.data
}
export async function voidOperatingExpense(id: number | string, reason: string) {
const { data } = await adminClient.post<ApiResponse<unknown>>(
`/admin/finance/operating-expenses/${id}/void`,
{ reason }
)
return data.data
}
+48
View File
@@ -354,3 +354,51 @@ export const announcementModeMap: Record<string, string> = {
inherit: '继承主站公告',
own_only: '仅本站公告',
}
export const financeDisbursementSourceMap: Record<string, string> = {
platform_managed: '平台代管',
pickup: '线下提号',
pickup_adjustment: '提号调整',
withdrawal: '钱包提现',
manual_offline: '线下出款',
}
export const financeDisbursementStatusMap: Record<string, StatusMeta> = {
review_pending: { label: '待审核', tone: 'gold' },
payment_pending: { label: '待打款', tone: 'blue' },
paid: { label: '已打款', tone: 'success' },
rejected: { label: '已驳回', tone: 'red' },
cancelled: { label: '已取消', tone: 'default' },
voided: { label: '已作废', tone: 'default' },
}
export const financeDisbursementStatusOptions = Object.entries(financeDisbursementStatusMap).map(
([value, meta]) => ({ value, label: meta.label })
)
export const financeDisbursementSourceOptions = Object.entries(financeDisbursementSourceMap).map(
([value, label]) => ({ value, label })
)
export const manualDisbursementCategoryMap: Record<string, string> = {
user_compensation: '用户补偿',
seller_supplement: '卖家补款',
operating_expense: '运营支出',
channel_fee: '渠道费用',
duoduo_deposit_refund: '多多退押金',
other: '其他',
custom: '自定义分类',
}
export const manualDisbursementCategoryOptions = Object.entries(manualDisbursementCategoryMap).map(
([value, label]) => ({ value, label })
)
export const operatingExpenseStatusMap: Record<string, StatusMeta> = {
paid: { label: '已记账', tone: 'success' },
voided: { label: '已作废', tone: 'default' },
}
export const operatingExpenseStatusOptions = Object.entries(operatingExpenseStatusMap).map(
([value, meta]) => ({ value, label: meta.label })
)
+129
View File
@@ -668,3 +668,132 @@ export interface AdminDistributionSiteUpdatePayload {
tax_rate_bps?: number
settlement_hold_days?: number
}
export interface FinanceDisbursementItem {
source_type: string
source_id: number
business_no: string
order_id?: number
withdrawal_id?: number
user_id: number
payee_name: string
payee_phone: string
uploader_name: string
source_channel: string
account_type: string
account_name: string
account_no: string
bank_name: string
amount_cent: number
fee_cent: number
actual_amount_cent: number
status: string
raw_status: string
business_created_at?: string | null
paid_at?: string | null
operator_id?: number | null
operator_name: string
remark: string
category: string
custom_category_name: string
voucher_url: string
voided_at?: string | null
voided_by?: number | null
voided_by_name: string
void_reason: string
}
export interface FinanceDisbursementListSummary {
record_count: number
paid_amount_cent: number
paid_count: number
payment_pending_amount_cent: number
payment_pending_count: number
review_pending_amount_cent: number
review_pending_count: number
withdrawal_fee_amount_cent: number
}
export interface FinanceDisbursementPage {
items: FinanceDisbursementItem[]
total: number
page: number
page_size: number
summary: FinanceDisbursementListSummary
}
export interface FinanceDisbursementQuery {
page?: number
page_size?: number
source_type?: string
status?: string
date_type?: 'created' | 'paid'
business_no?: string
keyword?: string
operator?: string
start_date?: string
end_date?: string
}
export interface ManualDisbursementPayload {
category: string
custom_category_name?: string
payee_name: string
amount_cent: number
paid_at: string
remark: string
voucher_url?: string
}
export interface OperatingExpense {
id: number
expense_no: string
category: string
payee_name: string
amount_cent: number
occurred_at: string
remark: string
voucher_url: string
status: string
created_by: number
created_by_name: string
voided_by?: number | null
voided_by_name: string
voided_at?: string | null
void_reason: string
created_at: string
}
export interface OperatingExpenseListSummary {
record_count: number
paid_amount_cent: number
paid_count: number
}
export interface OperatingExpensePage {
items: OperatingExpense[]
total: number
page: number
page_size: number
summary: OperatingExpenseListSummary
}
export interface OperatingExpenseQuery {
page?: number
page_size?: number
status?: string
category?: string
keyword?: string
date_type?: 'created' | 'occurred'
start_date?: string
end_date?: string
}
export interface OperatingExpensePayload {
category: string
payee_name: string
amount_cent: number
occurred_at: string
remark: string
voucher_url?: string
}