diff --git a/docs/Phase1-验收报告.md b/docs/Phase1-验收报告.md index 26cbcb9..ec39baa 100644 --- a/docs/Phase1-验收报告.md +++ b/docs/Phase1-验收报告.md @@ -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. 移动端、订单、分销轻改与首屏优化均已提交;商品管理、审核、退款、提现、用户、申诉、财务、资金出款、运营开支、公告、审计日志、系统配置与分销站点已按批次提交。 --- diff --git a/docs/Phase2-资金出款与运营开支验收.md b/docs/Phase2-资金出款与运营开支验收.md new file mode 100644 index 0000000..f7d5076 --- /dev/null +++ b/docs/Phase2-资金出款与运营开支验收.md @@ -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. 移动端下单、支付与交接/归还/结账。 diff --git a/docs/项目计划.md b/docs/项目计划.md index e0bd03f..3041195 100644 --- a/docs/项目计划.md +++ b/docs/项目计划.md @@ -395,7 +395,7 @@ hfb_jysh/ ### Phase 4:管理后台核心(4–5 周) -> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单、退款、提现、商品、用户、申诉、财务看板、公告管理、审计日志、系统配置与分销站点;线下出款/运营支出及分销长尾能力待迁移。 +> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单、退款、提现、商品、用户、申诉、财务看板、资金出款、运营开支、公告管理、审计日志、系统配置与分销站点;分销长尾能力待迁移。 - 后台登录、工作台、订单列表、订单详情。 - 商品列表、商品详情、审核。 diff --git a/frontend/src/app/AppRouter.tsx b/frontend/src/app/AppRouter.tsx index 460e700..28b7049 100644 --- a/frontend/src/app/AppRouter.tsx +++ b/frontend/src/app/AppRouter.tsx @@ -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() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/features/admin/AdminFinanceDisbursementsPage.tsx b/frontend/src/features/admin/AdminFinanceDisbursementsPage.tsx new file mode 100644 index 0000000..f41c2d4 --- /dev/null +++ b/frontend/src/features/admin/AdminFinanceDisbursementsPage.tsx @@ -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 { + 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' ? ( + + ) : 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' ? ( + + ) : null} + + ) : null + + return ( +
+ + 共 {summary?.record_count ?? 0} 笔 + 已打款 {summary?.paid_count ?? 0} 笔 + {isFetching && !isPending ? 正在更新 : null} + + } + extra={ + + {canManageManual ? ( + + ) : null} + + + } + /> + + {!canView ? ( + + ) : null} + + + + +
+ 已打款 + {formatMoney(summary?.paid_amount_cent ?? 0)} +
+ {summary?.paid_count ?? 0} 笔已完成出款 +
+ + + +
+ 待打款 + {formatMoney(summary?.payment_pending_amount_cent ?? 0)} +
+ {summary?.payment_pending_count ?? 0} 笔待处理 +
+ + + +
+ 待审核 + {summary?.review_pending_count ?? 0} +
+ {formatMoney(summary?.review_pending_amount_cent ?? 0)} 待审核 +
+ + + +
+ 提现手续费 + {formatMoney(summary?.withdrawal_fee_amount_cent ?? 0)} +
+ 当前筛选区间内已打款提现 +
+ +
+ + + + setDraft(current => ({ ...current, source_type: value ?? '' }))} + /> + + + setDraft(current => ({ ...current, keyword: event.target.value }))} + /> + + + + {isError ? ( + void refetch()}> + 重试 + + } + /> + ) : null} + + + title={出款明细} + extra={ + + {data ? `第 ${data.page} 页 · 当前 ${data.items.length} 条` : '加载中'} + + } + 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="当前筛选区间内没有出款记录" + /> + + setDetailOpen(false)} + title={selected ? `出款详情 · ${selected.business_no}` : '出款详情'} + subtitle={ + selected + ? `${sourceLabel(selected)} · ${formatDateTime(selected.business_created_at)}` + : undefined + } + tags={ + selected ? ( + + ) : undefined + } + sections={selected ? buildSections(selected) : []} + footer={detailFooter} + width={820} + emptyDescription="没有可展示的出款详情" + /> + + { + if (!creating) setCreateOpen(false) + }} + maskClosable={!creating} + closable={!creating} + destroyOnHidden + footer={ + + + + + } + > + form={form} layout="vertical" disabled={creating}> + + + + + + + + {category === 'custom' ? ( + + + + ) : null} + + + + + + + + + + + + + + + + + + + + + + { + if (!voidingSaving) setVoiding(null) + }} + maskClosable={!voidingSaving} + closable={!voidingSaving} + destroyOnHidden + footer={ + + + + + } + > + + setVoidReason(event.target.value)} + /> + +
+ ) +} 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' ? ( + + ) : 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' ? ( + + ) : null} + + ) : null + + return ( +
+ + 共 {summary?.record_count ?? 0} 笔 + 有效 {summary?.paid_count ?? 0} 笔 + {isFetching && !isPending ? 正在更新 : null} + + } + extra={ + + {canManage ? ( + + ) : null} + + + } + /> + + {!canManage ? ( + + ) : null} + + + + +
+ 有效开支 + {formatMoney(summary?.paid_amount_cent ?? 0)} +
+ {summary?.paid_count ?? 0} 笔已记账 +
+ + + +
+ 当前筛选记录 + {summary?.record_count ?? 0} +
+ 包含已作废记录 +
+ +
+ + + + setDraft(current => ({ ...current, status: value ?? '' }))} + /> + + + + String(option?.value ?? '').includes(input) + } + onChange={value => setDraft(current => ({ ...current, category: value ?? '' }))} + /> + + + setDraft(current => ({ ...current, keyword: event.target.value }))} + /> + + + + {isError ? ( + void refetch()}> + 重试 + + } + /> + ) : null} + + + title={开支明细} + extra={ + + {data ? `第 ${data.page} 页 · 当前 ${data.items.length} 条` : '加载中'} + + } + 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="当前筛选区间内没有运营开支" + /> + + setDetailOpen(false)} + title={selected ? `开支详情 · ${selected.expense_no}` : '开支详情'} + subtitle={selected ? `${selected.category} · ${formatDateTime(selected.occurred_at)}` : undefined} + tags={ + selected ? ( + + ) : undefined + } + sections={selected ? buildSections(selected) : []} + footer={detailFooter} + width={780} + emptyDescription="没有可展示的开支详情" + /> + + { + if (!creating) setCreateOpen(false) + }} + maskClosable={!creating} + closable={!creating} + destroyOnHidden + footer={ + + + + + } + > + form={form} layout="vertical" disabled={creating}> + + + + String(option?.value ?? '').includes(input)} + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + { + if (!voidingSaving) setVoiding(null) + }} + maskClosable={!voidingSaving} + closable={!voidingSaving} + destroyOnHidden + footer={ + + + + + } + > + + setVoidReason(event.target.value)} + /> + +
+ ) +} 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: '维护主站与分站公告。' },