feat(admin): 新增资金出款与运营开支管理
This commit is contained in:
+3
-3
@@ -194,12 +194,12 @@ dist/assets/AdminLayout-*.js 36.87 kB │ gzip 13.02 kB
|
||||
## 5. 已知限制
|
||||
|
||||
1. 移动端订单列表/详情与消息中心已接入真实接口;下单、支付、交接、归还、结账等操作仍待迁移。
|
||||
2. 后台导航已完成仪表盘、订单列表/详情、退款审核、提现审核、商品列表/详情与审核、用户列表/详情与人工操作、申诉仲裁、财务看板、公告管理、审计日志、系统配置、分销站点,长尾能力仍待迁移。
|
||||
2. 后台导航已完成仪表盘、订单列表/详情、退款审核、提现审核、商品列表/详情与审核、用户列表/详情与人工操作、申诉仲裁、财务看板、资金出款、运营开支、公告管理、审计日志、系统配置、分销站点,长尾能力仍待迁移。
|
||||
3. 分销后台已完成主题、Logo、登录页与 Shell 轻改,页面结构未重写。
|
||||
4. 后台菜单权限码按后端逐个核对:订单 `order:list` / `order:view` / `order:close`,商品 `listing:view` / `listing:approve` / `listing:reject` / `listing:offline`,用户 `user:view` / `user:freeze` / `user:unfreeze` / `user:deposit_free` / `user:growth_points` / `user:manual_realname` / `user:revoke_realname` / `user:wallet_adjust`,提现 `withdrawal:list` / `withdrawal:detail` / `withdrawal:review` / `withdrawal:pay`,申诉 `dispute:view` / `dispute:arbitrate`,财务 `wallet:view`,公告 `announcement:view` / `announcement:manage`,审计日志 `audit_log:view`,系统配置 `system_config:view` / `system_config:update`,分销站点 `distribution:site_view` / `distribution:site_manage`(列表也接受 `qrcode:view`);当前菜单权限码已全部对照后端。
|
||||
4. 后台菜单权限码按后端逐个核对:订单 `order:list` / `order:view` / `order:close`,商品 `listing:view` / `listing:approve` / `listing:reject` / `listing:offline`,用户 `user:view` / `user:freeze` / `user:unfreeze` / `user:deposit_free` / `user:growth_points` / `user:manual_realname` / `user:revoke_realname` / `user:wallet_adjust`,提现 `withdrawal:list` / `withdrawal:detail` / `withdrawal:review` / `withdrawal:pay`,申诉 `dispute:view` / `dispute:arbitrate`,财务 `wallet:view`(资金出款列表)与 `finance:manual_disbursement` / `finance:operating_expense`,公告 `announcement:view` / `announcement:manage`,审计日志 `audit_log:view`,系统配置 `system_config:view` / `system_config:update`,分销站点 `distribution:site_view` / `distribution:site_manage`(列表也接受 `qrcode:view`);当前菜单权限码已全部对照后端。
|
||||
5. SVG 中文尚未转曲。
|
||||
6. 入口包已降至 495 kB(gzip 161 kB);AntD 共享基础设施仍在入口,后续可拆独立入口。
|
||||
7. 移动端、订单、分销轻改与首屏优化均已提交;商品管理、审核、退款、提现、用户、申诉、财务、公告、审计日志、系统配置与分销站点已按批次提交。
|
||||
7. 移动端、订单、分销轻改与首屏优化均已提交;商品管理、审核、退款、提现、用户、申诉、财务、资金出款、运营开支、公告、审计日志、系统配置与分销站点已按批次提交。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# Phase 2 验收报告(资金出款与运营开支)
|
||||
|
||||
> 日期:2026-10-30
|
||||
> 项目:解忧商行(hfb_jysh)
|
||||
> 范围:`/console-jy8k3m/finance/disbursements`、`/console-jy8k3m/finance/operating-expenses`
|
||||
> 结论:**本切片通过,资金出款与运营开支的列表、汇总、补记和作废已接真实接口**
|
||||
|
||||
---
|
||||
|
||||
## 1. 接口与权限
|
||||
|
||||
| 能力 | 接口 | 权限码 |
|
||||
| --- | --- | --- |
|
||||
| 资金出款列表 | `GET /api/admin/finance/disbursements` | `wallet:view` |
|
||||
| 新增线下出款 | `POST /api/admin/finance/manual-disbursements` | `finance:manual_disbursement` |
|
||||
| 作废线下出款 | `POST /api/admin/finance/manual-disbursements/:id/void` | `finance:manual_disbursement` |
|
||||
| 运营开支列表 | `GET /api/admin/finance/operating-expenses` | `finance:operating_expense` |
|
||||
| 新增运营开支 | `POST /api/admin/finance/operating-expenses` | `finance:operating_expense` |
|
||||
| 作废运营开支 | `POST /api/admin/finance/operating-expenses/:id/void` | `finance:operating_expense` |
|
||||
|
||||
资金出款列表统一汇总五类来源:
|
||||
|
||||
```text
|
||||
platform_managed 平台代管出款
|
||||
pickup 线下提号结算
|
||||
pickup_adjustment 提号财务调整
|
||||
withdrawal 钱包提现
|
||||
manual_offline 手工线下出款
|
||||
```
|
||||
|
||||
## 2. 资金出款页
|
||||
|
||||
`frontend/src/features/admin/AdminFinanceDisbursementsPage.tsx`
|
||||
|
||||
- 筛选:日期类型(业务时间/打款时间)、日期范围、出款类型、状态、关键词
|
||||
- 汇总卡片:已打款金额与笔数、待打款、待审核、提现手续费
|
||||
- 列:出款单/类型、收款方/账户、金额/手续费/实付、状态、打款时间/操作人、备注、操作
|
||||
- 分页 10 / 20 / 50
|
||||
- 详情抽屉:出款信息、收款信息、金额、备注与凭证、作废信息
|
||||
- 仅 `manual_offline` 且 `paid` 的记录显示「作废」
|
||||
- 有 `finance:manual_disbursement` 时显示「新增线下出款」
|
||||
|
||||
新增线下出款字段:
|
||||
|
||||
- 出款分类(用户补偿、卖家补款、运营支出、渠道费用、多多退押金、其他、自定义)
|
||||
- 自定义分类名称(选择「自定义」时必填)
|
||||
- 收款人 / 单位
|
||||
- 出款金额(元,提交时转换为分)
|
||||
- 打款时间
|
||||
- 出款用途 / 备注
|
||||
- 打款凭证地址(可选)
|
||||
|
||||
作废必须填写作废原因,二次确认后提交。
|
||||
|
||||
## 3. 运营开支页
|
||||
|
||||
`frontend/src/features/admin/AdminOperatingExpensesPage.tsx`
|
||||
|
||||
- 筛选:日期类型(录入/实际开支)、日期范围、状态、开支类别、关键词
|
||||
- 汇总卡片:有效开支金额与笔数、当前筛选记录数
|
||||
- 列:开支单/类别、收款方、金额、实际开支时间、状态、录入人、用途、操作
|
||||
- 详情抽屉:开支信息、用途与凭证、作废信息
|
||||
- 已记账记录可作废,作废必须填写原因
|
||||
|
||||
新增运营开支字段:
|
||||
|
||||
- 开支类别(支持常用类别建议与自由输入)
|
||||
- 收款方 / 单位
|
||||
- 开支金额(元,提交时转换为分)
|
||||
- 开支发生时间
|
||||
- 开支用途 / 详细备注
|
||||
- 凭证 / 发票地址(可选)
|
||||
|
||||
## 4. 菜单与路由
|
||||
|
||||
- 路由:`frontend/src/app/AppRouter.tsx` 新增 `finance/disbursements`、`finance/operating-expenses`
|
||||
- 菜单:财务组新增「资金出款」(`wallet:view`)、「运营开支」(`finance:operating_expense`)
|
||||
- 面包屑标题与说明同步补充
|
||||
|
||||
## 5. 验证结果
|
||||
|
||||
```text
|
||||
npx tsc --noEmit 通过
|
||||
npm run build 通过(入口 498.51 kB,gzip 161.58 kB)
|
||||
GET /api/admin/finance/disbursements?... ok,开发库返回 0 条
|
||||
GET /api/admin/finance/operating-expenses?... ok,开发库返回 0 条
|
||||
```
|
||||
|
||||
开发库当前没有出款与运营开支记录;列表与汇总读取已实测,未执行新增或作废写操作。
|
||||
|
||||
## 6. 已知限制
|
||||
|
||||
1. 打款凭证与发票目前只支持填写地址,尚未接后台文件上传。
|
||||
2. 线下出款与运营开支只支持新增和作废,不支持编辑金额或替换凭证。
|
||||
3. 资金出款页没有按操作人 ID、用户 ID 单独筛选,关键词已覆盖单号、收款方与账户。
|
||||
4. 作废原因最多 200 字,未做附件或审批流。
|
||||
5. 财务看板尚未直接跳转到对应出款/开支明细。
|
||||
|
||||
## 7. 下一步建议
|
||||
|
||||
1. 接入后台文件上传,补齐凭证拍照与预览。
|
||||
2. 财务看板增加出款与开支明细跳转。
|
||||
3. 分销站点钱包与提现审核。
|
||||
4. 移动端下单、支付与交接/归还/结账。
|
||||
+1
-1
@@ -395,7 +395,7 @@ hfb_jysh/
|
||||
|
||||
### Phase 4:管理后台核心(4–5 周)
|
||||
|
||||
> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单、退款、提现、商品、用户、申诉、财务看板、公告管理、审计日志、系统配置与分销站点;线下出款/运营支出及分销长尾能力待迁移。
|
||||
> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单、退款、提现、商品、用户、申诉、财务看板、资金出款、运营开支、公告管理、审计日志、系统配置与分销站点;分销长尾能力待迁移。
|
||||
|
||||
- 后台登录、工作台、订单列表、订单详情。
|
||||
- 商品列表、商品详情、审核。
|
||||
|
||||
@@ -98,6 +98,16 @@ const AdminFinancePage = lazy(() =>
|
||||
default: module.AdminFinancePage,
|
||||
}))
|
||||
)
|
||||
const AdminFinanceDisbursementsPage = lazy(() =>
|
||||
import('@/features/admin/AdminFinanceDisbursementsPage').then(module => ({
|
||||
default: module.AdminFinanceDisbursementsPage,
|
||||
}))
|
||||
)
|
||||
const AdminOperatingExpensesPage = lazy(() =>
|
||||
import('@/features/admin/AdminOperatingExpensesPage').then(module => ({
|
||||
default: module.AdminOperatingExpensesPage,
|
||||
}))
|
||||
)
|
||||
const AdminAnnouncementsPage = lazy(() =>
|
||||
import('@/features/admin/AdminAnnouncementsPage').then(module => ({
|
||||
default: module.AdminAnnouncementsPage,
|
||||
@@ -162,6 +172,8 @@ export function AppRouter() {
|
||||
<Route path="withdrawals" element={<AdminWithdrawalsPage />} />
|
||||
<Route path="disputes" element={<AdminDisputesPage />} />
|
||||
<Route path="finance" element={<AdminFinancePage />} />
|
||||
<Route path="finance/disbursements" element={<AdminFinanceDisbursementsPage />} />
|
||||
<Route path="finance/operating-expenses" element={<AdminOperatingExpensesPage />} />
|
||||
<Route path="announcements" element={<AdminAnnouncementsPage />} />
|
||||
<Route path="audit-logs" element={<AdminAuditLogsPage />} />
|
||||
<Route path="system-configs" element={<AdminSystemConfigsPage />} />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -84,6 +84,12 @@ const allNavGroups: NavGroup[] = [
|
||||
icon: <AccountBookOutlined />,
|
||||
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<string, { title: string; description: string }> = {
|
||||
'/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: '维护主站与分站公告。' },
|
||||
|
||||
Reference in New Issue
Block a user