{
+ return [
+ {
+ title: '出款单',
+ dataIndex: 'business_no',
+ width: 230,
+ fixed: 'left',
+ render: (_, row) => (
+
+
+
+ {sourceLabel(row)}
+ {row.source_type === 'manual_offline' ? ` · ${categoryLabel(row)}` : ''}
+
+ {formatDateTime(row.business_created_at)}
+
+ ),
+ },
+ {
+ title: '收款方',
+ key: 'payee',
+ width: 230,
+ render: (_, row) => (
+
+ {formatText(row.payee_name)}
+ {formatText(row.payee_phone)}
+
+ {row.account_type === 'bank'
+ ? `${formatText(row.bank_name)} · ${formatText(row.account_no)}`
+ : formatText(row.account_name || row.account_no)}
+
+
+ ),
+ },
+ {
+ title: '金额',
+ key: 'amount',
+ width: 160,
+ align: 'right',
+ render: (_, row) => (
+
+ {formatMoney(row.amount_cent)}
+ 手续费 {formatMoney(row.fee_cent)}
+ 实付 {formatMoney(row.actual_amount_cent)}
+
+ ),
+ },
+ {
+ title: '状态',
+ dataIndex: 'status',
+ width: 110,
+ render: value => ,
+ },
+ {
+ title: '打款 / 操作人',
+ key: 'paid',
+ width: 190,
+ render: (_, row) => (
+
+ 打款 {formatDateTime(row.paid_at)}
+ 操作 {row.operator_name || '—'}
+ {formatDateTime(row.business_created_at)}
+
+ ),
+ },
+ {
+ title: '备注',
+ dataIndex: 'remark',
+ width: 240,
+ render: value => (
+
+ {formatText(value)}
+
+ ),
+ },
+ {
+ title: '操作',
+ key: 'actions',
+ width: 150,
+ fixed: 'right',
+ render: (_, row) => (
+
+
+ {canManage && row.source_type === 'manual_offline' && row.status === 'paid' ? (
+ }
+ onClick={() => onVoid(row)}
+ >
+ 作废
+
+ ) : null}
+
+ ),
+ },
+ ]
+}
+
+function buildSections(item: FinanceDisbursementItem): DetailSection[] {
+ return [
+ {
+ key: 'base',
+ title: '出款信息',
+ fields: [
+ {
+ key: 'no',
+ label: '出款单号',
+ value: {item.business_no},
+ },
+ {
+ key: 'source',
+ label: '出款类型',
+ value: (
+
+ {sourceLabel(item)}
+ {item.source_type === 'manual_offline' ? {categoryLabel(item)} : null}
+
+ ),
+ },
+ {
+ key: 'status',
+ label: '状态',
+ value: ,
+ },
+ {
+ key: 'raw_status',
+ label: '原始状态',
+ value: {formatText(item.raw_status)},
+ },
+ {
+ 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: (
+
+ {formatText(item.account_type)}
+ {formatText(item.account_name)}
+ {formatText(item.account_no)}
+
+ ),
+ },
+ { 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: {formatMoney(item.amount_cent)} },
+ { key: 'fee', label: '手续费', value: {formatMoney(item.fee_cent)} },
+ {
+ key: 'actual',
+ label: '实际出款',
+ value: {formatMoney(item.actual_amount_cent)},
+ },
+ { 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 ? (
+
+ {item.voucher_url}
+
+ ) : (
+ '未上传凭证'
+ ),
+ },
+ ...(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()
+ const [draft, setDraft] = useState(defaultFilters)
+ const [applied, setApplied] = useState(defaultFilters)
+ const [page, setPage] = useState(1)
+ const [pageSize, setPageSize] = useState(20)
+ const [selected, setSelected] = useState(null)
+ const [detailOpen, setDetailOpen] = useState(false)
+ const [createOpen, setCreateOpen] = useState(false)
+ const [creating, setCreating] = useState(false)
+ const [voiding, setVoiding] = useState(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 ? (
+
+
+ {canManageManual &&
+ selected.source_type === 'manual_offline' &&
+ selected.status === 'paid' ? (
+ }
+ onClick={() => {
+ setVoiding(selected)
+ setVoidReason('')
+ }}
+ >
+ 作废出款
+
+ ) : null}
+
+ ) : null
+
+ return (
+
+ )
+}
diff --git a/frontend/src/features/admin/AdminOperatingExpensesPage.tsx b/frontend/src/features/admin/AdminOperatingExpensesPage.tsx
new file mode 100644
index 0000000..e158b46
--- /dev/null
+++ b/frontend/src/features/admin/AdminOperatingExpensesPage.tsx
@@ -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 {
+ return [
+ {
+ title: '开支单',
+ dataIndex: 'expense_no',
+ width: 230,
+ fixed: 'left',
+ render: (_, row) => (
+
+
+ {formatText(row.category)}
+ {formatDateTime(row.created_at)}
+
+ ),
+ },
+ {
+ title: '收款方',
+ dataIndex: 'payee_name',
+ width: 180,
+ render: value => {formatText(value)},
+ },
+ {
+ title: '开支金额',
+ dataIndex: 'amount_cent',
+ width: 150,
+ align: 'right',
+ render: value => {formatMoney(value)},
+ },
+ {
+ title: '实际开支时间',
+ dataIndex: 'occurred_at',
+ width: 170,
+ render: value => formatDateTime(value),
+ },
+ {
+ title: '状态',
+ dataIndex: 'status',
+ width: 100,
+ render: value => ,
+ },
+ {
+ title: '录入人',
+ dataIndex: 'created_by_name',
+ width: 130,
+ render: value => formatText(value),
+ },
+ {
+ title: '用途 / 备注',
+ dataIndex: 'remark',
+ width: 280,
+ render: value => (
+
+ {formatText(value)}
+
+ ),
+ },
+ {
+ title: '操作',
+ key: 'actions',
+ width: 150,
+ fixed: 'right',
+ render: (_, row) => (
+
+
+ {canManage && row.status === 'paid' ? (
+ }
+ onClick={() => onVoid(row)}
+ >
+ 作废
+
+ ) : null}
+
+ ),
+ },
+ ]
+}
+
+function buildSections(item: OperatingExpense): DetailSection[] {
+ return [
+ {
+ key: 'base',
+ title: '开支信息',
+ fields: [
+ {
+ key: 'no',
+ label: '开支单号',
+ value: {item.expense_no},
+ },
+ { key: 'category', label: '开支类别', value: formatText(item.category) },
+ {
+ key: 'status',
+ label: '状态',
+ value: ,
+ },
+ { key: 'payee', label: '收款方', value: formatText(item.payee_name) },
+ {
+ key: 'amount',
+ label: '开支金额',
+ value: {formatMoney(item.amount_cent)},
+ },
+ { 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 ? (
+
+ {item.voucher_url}
+
+ ) : (
+ '未上传凭证'
+ ),
+ },
+ ...(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()
+ const [draft, setDraft] = useState(defaultFilters)
+ const [applied, setApplied] = useState(defaultFilters)
+ const [page, setPage] = useState(1)
+ const [pageSize, setPageSize] = useState(20)
+ const [selected, setSelected] = useState(null)
+ const [detailOpen, setDetailOpen] = useState(false)
+ const [createOpen, setCreateOpen] = useState(false)
+ const [creating, setCreating] = useState(false)
+ const [voiding, setVoiding] = useState(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 ? (
+
+
+ {canManage && selected.status === 'paid' ? (
+ }
+ onClick={() => {
+ setVoiding(selected)
+ setVoidReason('')
+ }}
+ >
+ 作废开支
+
+ ) : null}
+
+ ) : null
+
+ return (
+
+ )
+}
diff --git a/frontend/src/features/admin/api.ts b/frontend/src/features/admin/api.ts
index 2674b5f..c04d1ea 100644
--- a/frontend/src/features/admin/api.ts
+++ b/frontend/src/features/admin/api.ts
@@ -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>>(
+ '/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>(
+ '/admin/finance/manual-disbursements',
+ payload
+ )
+ return data.data
+}
+
+export async function voidManualDisbursement(id: number | string, reason: string) {
+ const { data } = await adminClient.post>(
+ `/admin/finance/manual-disbursements/${id}/void`,
+ { reason }
+ )
+ return data.data
+}
+
+export async function fetchOperatingExpenses(query: OperatingExpenseQuery) {
+ const { data } = await adminClient.get>>(
+ '/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>(
+ '/admin/finance/operating-expenses',
+ payload
+ )
+ return data.data
+}
+
+export async function voidOperatingExpense(id: number | string, reason: string) {
+ const { data } = await adminClient.post>(
+ `/admin/finance/operating-expenses/${id}/void`,
+ { reason }
+ )
+ return data.data
+}
diff --git a/frontend/src/features/admin/labels.ts b/frontend/src/features/admin/labels.ts
index 1374163..a7f4fac 100644
--- a/frontend/src/features/admin/labels.ts
+++ b/frontend/src/features/admin/labels.ts
@@ -354,3 +354,51 @@ export const announcementModeMap: Record = {
inherit: '继承主站公告',
own_only: '仅本站公告',
}
+
+export const financeDisbursementSourceMap: Record = {
+ platform_managed: '平台代管',
+ pickup: '线下提号',
+ pickup_adjustment: '提号调整',
+ withdrawal: '钱包提现',
+ manual_offline: '线下出款',
+}
+
+export const financeDisbursementStatusMap: Record = {
+ 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 = {
+ 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 = {
+ paid: { label: '已记账', tone: 'success' },
+ voided: { label: '已作废', tone: 'default' },
+}
+
+export const operatingExpenseStatusOptions = Object.entries(operatingExpenseStatusMap).map(
+ ([value, meta]) => ({ value, label: meta.label })
+)
diff --git a/frontend/src/features/admin/types.ts b/frontend/src/features/admin/types.ts
index f53bce3..fab321a 100644
--- a/frontend/src/features/admin/types.ts
+++ b/frontend/src/features/admin/types.ts
@@ -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
+}
diff --git a/frontend/src/layouts/AdminLayout.tsx b/frontend/src/layouts/AdminLayout.tsx
index 30f5914..13c9a4c 100644
--- a/frontend/src/layouts/AdminLayout.tsx
+++ b/frontend/src/layouts/AdminLayout.tsx
@@ -84,6 +84,12 @@ const allNavGroups: NavGroup[] = [
icon: ,
children: [
{ key: '/finance', label: '财务看板', permission: 'wallet:view' },
+ { key: '/finance/disbursements', label: '资金出款', permission: 'wallet:view' },
+ {
+ key: '/finance/operating-expenses',
+ label: '运营开支',
+ permission: 'finance:operating_expense',
+ },
{ key: '/withdrawals', label: '提现审核', permission: 'withdrawal:list' },
],
},
@@ -120,6 +126,8 @@ const sectionMeta: Record = {
'/listings/review': { title: '商品审核', description: '审核待上架商品与账号截图。' },
'/users': { title: '用户管理', description: '查看用户、实名状态与交易记录。' },
'/finance': { title: '财务看板', description: '查看收入、退款、结算与资金趋势。' },
+ '/finance/disbursements': { title: '资金出款', description: '查看代管出款、提现与线下出款明细。' },
+ '/finance/operating-expenses': { title: '运营开支', description: '登记与核对日常运营支出。' },
'/withdrawals': { title: '提现审核', description: '审核提现申请与打款确认。' },
'/distribution/sites': { title: '分销站点', description: '管理分站、规则、结算与站点钱包。' },
'/announcements': { title: '公告管理', description: '维护主站与分站公告。' },