Files
order_site/apps/frontend/src/pages/admin/panels/FinancePanel.tsx
T

868 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { ReloadOutlined } from '@ant-design/icons'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
App,
Alert,
Button,
Card,
Descriptions,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Switch,
Table,
Tag,
Typography,
} from 'antd'
import type { TableColumnsType } from 'antd'
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router'
import ImageUpload from '@/components/files/ImageUpload'
import ImagePreviewList from '@/components/files/ImagePreviewList'
import {
fetchAdminWorkerFinanceConfig,
fetchAdminWorkerFinanceRequests,
reviewAdminWorkerFinanceRequest,
saveAdminWorkerFinanceConfig,
} from '@/services/admin'
import type {
UploadedFile,
WorkerFinanceConfig,
WorkerFinanceRequest,
} from '@/types/worker-platform'
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
import dayjs, { ADMIN_DATE_TIME_FORMAT } from '@/lib/dayjs'
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
import { formatAdminDateTime } from '@/utils/admin-time'
import { asRecord, formatMoney } from './shared'
type FinanceConfigFormValues = {
depositUnfreezeDays?: number
rewardUnfreezeEnabled?: boolean
recharge?: {
enabled?: boolean
channelName?: string
accountName?: string
accountNo?: string
qrCodeImageList?: UploadedFile[]
instructions?: string
}
adminContact?: {
wechatQrCodeImageList?: UploadedFile[]
}
withdraw?: {
enabled?: boolean
instructions?: string
}
}
type FinanceReviewAction = 'approved' | 'rejected' | 'cancelled'
export default function FinancePanel() {
const { message } = App.useApp()
const queryClient = useQueryClient()
const [searchParams, setSearchParams] = useSearchParams()
const [configForm] = Form.useForm<FinanceConfigFormValues>()
const [reviewForm] = Form.useForm<{ reviewedNote?: string }>()
const [requestStatus, setRequestStatus] = useState('')
const [targetRequestId, setTargetRequestId] = useState<number | undefined>()
const [requestType, setRequestType] = useState('')
const [keywordInput, setKeywordInput] = useState('')
const [keyword, setKeyword] = useState('')
const [createdRange, setCreatedRange] = useState<[string, string] | null>(null)
const [accountChannel, setAccountChannel] = useState('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
const [savingConfig, setSavingConfig] = useState(false)
const [configExpanded, setConfigExpanded] = useState(false)
const [reviewing, setReviewing] = useState(false)
const [reviewState, setReviewState] = useState<{
action: FinanceReviewAction
request: WorkerFinanceRequest
} | null>(null)
const financeConfigQuery = useQuery({
queryKey: ['admin-worker-platform-finance-config'],
queryFn: () => fetchAdminWorkerFinanceConfig(),
})
useEffect(() => {
const requestId = Number(searchParams.get('financeRequestId') || 0)
if (!Number.isInteger(requestId) || requestId <= 0) return
setTargetRequestId(requestId)
setRequestStatus('')
setRequestType('')
setKeyword('')
setKeywordInput('')
setPage(1)
setSearchParams(
(current) => {
current.delete('financeRequestId')
return current
},
{ replace: true },
)
}, [searchParams, setSearchParams])
const financeRequestsQuery = useQuery({
queryKey: [
'admin-worker-platform-finance-requests',
requestStatus,
requestType,
targetRequestId,
keyword,
page,
pageSize,
createdRange ? createdRange[0] : '',
createdRange ? createdRange[1] : '',
accountChannel,
],
queryFn: () =>
fetchAdminWorkerFinanceRequests({
status: requestStatus,
requestType,
requestId: targetRequestId,
keyword,
page,
pageSize,
accountChannel,
...(createdRange ? { createdFrom: createdRange[0], createdTo: createdRange[1] } : {}),
}),
})
const financeRequestsPagination = financeRequestsQuery.data?.data.pagination
useEffect(() => {
const config = financeConfigQuery.data?.data
if (!config) {
return
}
configForm.setFieldsValue(mapFinanceConfigToFormValues(config))
}, [financeConfigQuery.data, configForm])
async function refreshAll() {
await Promise.all([
queryClient.invalidateQueries({
queryKey: ['admin-worker-platform-finance-config'],
}),
queryClient.invalidateQueries({
queryKey: ['admin-worker-platform-finance-requests'],
}),
queryClient.invalidateQueries({
queryKey: ['admin-worker-platform-workers'],
}),
])
}
async function submitFinanceConfig(values: FinanceConfigFormValues) {
setSavingConfig(true)
try {
await saveAdminWorkerFinanceConfig({
depositUnfreezeDays: Number(values.depositUnfreezeDays ?? 3),
rewardUnfreezeEnabled: values.rewardUnfreezeEnabled !== false,
recharge: {
enabled: values.recharge?.enabled !== false,
channelName: String(values.recharge?.channelName || '').trim(),
accountName: String(values.recharge?.accountName || '').trim(),
accountNo: String(values.recharge?.accountNo || '').trim(),
qrCodeImage: values.recharge?.qrCodeImageList?.[0] || null,
instructions: String(values.recharge?.instructions || '').trim(),
},
adminContact: {
wechatQrCodeImage: values.adminContact?.wechatQrCodeImageList?.[0] || null,
},
withdraw: {
enabled: values.withdraw?.enabled !== false,
instructions: String(values.withdraw?.instructions || '').trim(),
},
})
message.success('资金配置已保存')
await refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '保存资金配置失败')
} finally {
setSavingConfig(false)
}
}
function openReviewModal(request: WorkerFinanceRequest, action: FinanceReviewAction) {
setReviewState({ request, action })
reviewForm.setFieldsValue({ reviewedNote: '' })
}
async function submitFinanceReview(values: { reviewedNote?: string }) {
if (!reviewState) return
setReviewing(true)
try {
await reviewAdminWorkerFinanceRequest(reviewState.request.requestId, {
status: reviewState.action,
reviewedNote: String(values.reviewedNote || '').trim(),
})
message.success(`${formatFinanceReviewAction(reviewState.action)}成功`)
setReviewState(null)
reviewForm.resetFields()
await refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '处理资金申请失败')
} finally {
setReviewing(false)
}
}
const requestColumns: TableColumnsType<WorkerFinanceRequest> = [
{
title: '打手',
minWidth: 250,
render: (_, row) => (
<div className="cell-stack">
<Typography.Text strong>
{row.worker?.displayName || row.worker?.username || '-'}
</Typography.Text>
<Typography.Text type="secondary">{row.worker?.username || '-'}</Typography.Text>
{row.worker ? (
<Space size={[4, 4]} wrap>
<Tag color={row.worker.workerType === 'internal' ? 'blue' : 'default'}>
{row.worker.workerType === 'internal' ? '内部打手' : '外部打手'}
</Tag>
<Tag>{row.worker.levelName || row.worker.levelKey || '未分级'}</Tag>
<Typography.Text type="secondary">
提现 {row.worker.withdrawRequestCount} 次,累计
{formatMoney(row.worker.totalWithdrawAmount)}
</Typography.Text>
</Space>
) : null}
</div>
),
},
{
title: '类型',
width: 120,
render: (_, row) => (
<Tag color={row.requestType === 'withdraw' ? 'green' : 'blue'}>
{formatFinanceRequestType(row.requestType)}
</Tag>
),
},
{
title: '金额',
width: 120,
render: (_, row) => formatMoney(row.amount),
},
{
title: '收款信息',
minWidth: 240,
render: (_, row) => renderFinanceRequestAccount(row),
},
{
title: '凭证/收款码',
minWidth: 140,
render: (_, row) => (
<ImagePreviewList
files={row.requestType === 'recharge' ? getRechargeProofs(row) : getWithdrawQrCode(row)}
size={48}
/>
),
},
{
title: '备注',
minWidth: 260,
render: (_, row) => (
<div className="cell-stack">
<Typography.Text>{row.note || '-'}</Typography.Text>
{row.reviewedNote ? (
<Typography.Text type="secondary">审核备注:{row.reviewedNote}</Typography.Text>
) : null}
</div>
),
},
{
title: '状态',
width: 120,
render: (_, row) => (
<Tag color={resolveFinanceRequestStatusColor(row.status)}>
{formatFinanceRequestStatus(row.status)}
</Tag>
),
},
{
title: '时间',
width: 180,
render: (_, row) => (
<div className="cell-stack">
<Typography.Text>{formatAdminDateTime(row.createdAt)}</Typography.Text>
<Typography.Text type="secondary">
{row.reviewedAt
? `审核:${formatAdminDateTime(row.reviewedAt)}${resolveFinanceReviewer(row) ? `${resolveFinanceReviewer(row)}` : ''}`
: '待审核'}
</Typography.Text>
</div>
),
},
{
title: '操作',
width: 240,
render: (_, row) =>
row.status === 'pending' ? (
<Space wrap>
<Button type="primary" onClick={() => openReviewModal(row, 'approved')}>
通过
</Button>
<Button onClick={() => openReviewModal(row, 'rejected')}>拒绝</Button>
<Button danger onClick={() => openReviewModal(row, 'cancelled')}>
取消
</Button>
</Space>
) : (
<Typography.Text type="secondary">已处理</Typography.Text>
),
},
]
return (
<section className="platform-panel-stack">
{targetRequestId ? (
<Alert
type="info"
showIcon
message={`已定位资金申请 #${targetRequestId}`}
action={
<Button size="small" onClick={() => setTargetRequestId(undefined)}>
取消定位
</Button>
}
/>
) : null}
<Card
title="资金配置"
bordered={false}
loading={financeConfigQuery.isLoading}
extra={
<Space>
<Button type="link" onClick={() => setConfigExpanded((expanded) => !expanded)}>
{configExpanded ? '收起配置' : '展开配置'}
</Button>
<Button
icon={<ReloadOutlined />}
loading={financeConfigQuery.isFetching}
onClick={() => financeConfigQuery.refetch()}
>
刷新
</Button>
</Space>
}
>
{configExpanded && financeConfigQuery.error ? (
<Typography.Text type="danger">
{financeConfigQuery.error instanceof Error
? financeConfigQuery.error.message
: '读取资金配置失败'}
</Typography.Text>
) : null}
{configExpanded ? (
<Form
form={configForm}
layout="vertical"
onFinish={submitFinanceConfig}
initialValues={mapFinanceConfigToFormValues()}
>
<Card title="充值入口" size="small" style={{ marginBottom: 16 }}>
<Form.Item
label="开启充值申请"
name={['recharge', 'enabled']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item label="收款通道名称" name={['recharge', 'channelName']}>
<Input placeholder="如:支付宝扫码、微信收款" />
</Form.Item>
<Form.Item label="收款人" name={['recharge', 'accountName']}>
<Input placeholder="请输入收款人姓名" />
</Form.Item>
<Form.Item label="收款账号" name={['recharge', 'accountNo']}>
<Input placeholder="请输入支付宝账号、微信号或银行卡号" />
</Form.Item>
<Form.Item label="收款二维码" name={['recharge', 'qrCodeImageList']}>
<ImageUpload scene="worker-finance" scope="admin" maxCount={1} />
</Form.Item>
<Form.Item label="充值说明" name={['recharge', 'instructions']}>
<Input.TextArea
rows={4}
placeholder="例如:转账后请填写付款时间、付款尾号、截图说明。"
/>
</Form.Item>
</Card>
<Card title="管理员联系" size="small" style={{ marginBottom: 16 }}>
<Form.Item label="管理员微信二维码" name={['adminContact', 'wechatQrCodeImageList']}>
<ImageUpload scene="worker-admin-contact" scope="admin" maxCount={1} />
</Form.Item>
</Card>
<Card title="押金与提现规则" size="small" style={{ marginBottom: 16 }}>
<Form.Item
label="押金解冻天数"
name="depositUnfreezeDays"
tooltip="验收通过后,押金进入待解冻,N 天后自动转入可用余额;0 表示押金立即释放"
>
<InputNumber min={0} max={30} step={1} addonAfter="天" />
</Form.Item>
<Form.Item
label="报酬待解冻"
name="rewardUnfreezeEnabled"
valuePropName="checked"
tooltip="开启后,验收报酬和押金一起进入待解冻;期间可由售后问题单优先扣减"
>
<Switch />
</Form.Item>
<Typography.Text type="secondary">
解冻期间如订单出现问题,可在"接单工单""售后问题单"中优先扣减本单待解冻金额。
</Typography.Text>
</Card>
<Card title="提现入口" size="small" style={{ marginBottom: 16 }}>
<Form.Item
label="开启提现申请"
name={['withdraw', 'enabled']}
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item label="提现说明" name={['withdraw', 'instructions']}>
<Input.TextArea
rows={4}
placeholder="例如:每天 18:00 前审核,当日打款;银行卡请备注开户行。"
/>
</Form.Item>
</Card>
<Button type="primary" htmlType="submit" loading={savingConfig}>
保存配置
</Button>
</Form>
) : null}
</Card>
<Card
title="资金申请"
bordered={false}
extra={
<Space wrap>
<Input.Search
allowClear
value={keywordInput}
placeholder="搜索打手账号/昵称/手机号"
style={{ width: 240 }}
onChange={(event) => setKeywordInput(event.target.value)}
onSearch={(value) => {
setKeyword(String(value || '').trim())
setPage(1)
}}
/>
<Select
value={requestType}
style={{ width: 140 }}
options={[
{ value: '', label: '全部类型' },
{ value: 'recharge', label: '充值申请' },
{ value: 'withdraw', label: '提现申请' },
]}
onChange={(nextType) => {
setRequestType(nextType)
setPage(1)
}}
/>
<Select
value={requestStatus}
style={{ width: 140 }}
options={[
{ value: '', label: '全部状态' },
{ value: 'pending', label: '待处理' },
{ value: 'approved', label: '已通过' },
{ value: 'rejected', label: '已拒绝' },
{ value: 'cancelled', label: '已取消' },
]}
onChange={(nextStatus) => {
setRequestStatus(nextStatus)
setPage(1)
}}
/>
<Select
value={accountChannel}
style={{ width: 130 }}
options={[
{ value: '', label: '全部渠道' },
{ value: 'alipay', label: '支付宝' },
{ value: 'wechat', label: '微信' },
]}
onChange={(nextChannel) => {
setAccountChannel(nextChannel)
setPage(1)
}}
/>
<AdminRangePicker
format="YYYY-MM-DD HH:mm"
showTime={{
format: 'HH:mm',
defaultValue: [dayjs('00:00:00', 'HH:mm:ss'), dayjs('23:59:59', 'HH:mm:ss')],
}}
value={createdRange ? [dayjs(createdRange[0]), dayjs(createdRange[1])] : null}
onChange={(dates) => {
const [start, end] = dates || []
setCreatedRange(
start && end
? [
// 起止各占满整分钟:开始 xx:00、结束 xx:59
start.second(0).format(ADMIN_DATE_TIME_FORMAT),
end.second(59).format(ADMIN_DATE_TIME_FORMAT),
]
: null,
)
setPage(1)
}}
/>
<Button
icon={<ReloadOutlined />}
loading={financeRequestsQuery.isFetching}
onClick={() => financeRequestsQuery.refetch()}
>
刷新
</Button>
</Space>
}
>
<FinanceSummaryBar
channels={financeRequestsQuery.data?.data.summary?.channels}
totalCount={financeRequestsQuery.data?.data.summary?.totalCount}
totalAmount={financeRequestsQuery.data?.data.summary?.totalAmount}
/>
<Table<WorkerFinanceRequest>
rowKey="requestId"
loading={financeRequestsQuery.isLoading}
dataSource={financeRequestsQuery.data?.data.items || []}
columns={requestColumns}
pagination={buildAdminTablePagination({
current: financeRequestsPagination?.page || page,
pageSize: financeRequestsPagination?.pageSize || pageSize,
total: financeRequestsPagination?.total || 0,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage)
setPageSize(nextPageSize)
},
})}
scroll={{ x: 1380 }}
/>
</Card>
<Modal
title={
reviewState ? `${formatFinanceReviewAction(reviewState.action)}资金申请` : '处理资金申请'
}
open={Boolean(reviewState)}
destroyOnHidden
width="min(860px, calc(100vw - 32px))"
confirmLoading={reviewing}
onCancel={() => {
setReviewState(null)
reviewForm.resetFields()
}}
onOk={() => reviewForm.submit()}
>
{reviewState ? (
<Space direction="vertical" size={16} className="full-width">
<div className="finance-review-layout">
<div className="finance-review-details">
<Typography.Text strong>
{reviewState.request.requestType === 'withdraw' ? '提现信息' : '充值信息'}
</Typography.Text>
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="打手">
{reviewState.request.worker?.displayName ||
reviewState.request.worker?.username ||
'-'}
</Descriptions.Item>
<Descriptions.Item label="申请类型">
{formatFinanceRequestType(reviewState.request.requestType)}
</Descriptions.Item>
<Descriptions.Item label="金额">
{formatMoney(reviewState.request.amount)}
</Descriptions.Item>
<Descriptions.Item label="收款信息">
{renderFinanceRequestAccountText(reviewState.request)}
</Descriptions.Item>
{reviewState.request.requestType === 'withdraw' ? (
<Descriptions.Item label="收款账号">
{reviewState.request.accountNo ? (
<Typography.Text copyable>{reviewState.request.accountNo}</Typography.Text>
) : (
'-'
)}
</Descriptions.Item>
) : null}
{reviewState.request.requestType === 'recharge' ? (
<Descriptions.Item label="付款时间">
{getRechargePaidAt(reviewState.request)
? formatAdminDateTime(getRechargePaidAt(reviewState.request))
: '-'}
</Descriptions.Item>
) : null}
<Descriptions.Item label="申请备注">
{reviewState.request.note || '-'}
</Descriptions.Item>
</Descriptions>
</div>
<div className="finance-review-evidence">
<Typography.Text strong>
{reviewState.request.requestType === 'withdraw'
? `${formatFinanceChannel(reviewState.request.accountChannel)}收款二维码`
: '付款凭证'}
</Typography.Text>
<ImagePreviewList
files={
reviewState.request.requestType === 'withdraw'
? getWithdrawQrCode(reviewState.request)
: getRechargeProofs(reviewState.request)
}
size={280}
/>
</div>
</div>
<Form form={reviewForm} layout="vertical" onFinish={submitFinanceReview}>
<Form.Item label="审核备注" name="reviewedNote">
<Input.TextArea
rows={4}
placeholder={`请输入${formatFinanceReviewAction(reviewState.action)}备注`}
/>
</Form.Item>
</Form>
</Space>
) : null}
</Modal>
</section>
)
}
function mapFinanceConfigToFormValues(config?: WorkerFinanceConfig): FinanceConfigFormValues {
return {
depositUnfreezeDays: Number(config?.depositUnfreezeDays ?? 3),
rewardUnfreezeEnabled: config?.rewardUnfreezeEnabled !== false,
recharge: {
enabled: config?.recharge.enabled !== false,
channelName: config?.recharge.channelName || '',
accountName: config?.recharge.accountName || '',
accountNo: config?.recharge.accountNo || '',
qrCodeImageList: config?.recharge.qrCodeImage ? [config.recharge.qrCodeImage] : [],
instructions: config?.recharge.instructions || '',
},
adminContact: {
wechatQrCodeImageList: config?.adminContact.wechatQrCodeImage
? [config.adminContact.wechatQrCodeImage]
: [],
},
withdraw: {
enabled: config?.withdraw.enabled !== false,
instructions: config?.withdraw.instructions || '',
},
}
}
function formatFinanceReviewAction(action: FinanceReviewAction) {
if (action === 'approved') return '通过'
if (action === 'rejected') return '拒绝'
return '取消'
}
function formatFinanceRequestType(requestType: string) {
if (requestType === 'recharge') return '充值申请'
if (requestType === 'withdraw') return '提现申请'
return requestType || '-'
}
function formatFinanceRequestStatus(status: string) {
const labels: Record<string, string> = {
pending: '待处理',
approved: '已通过',
rejected: '已拒绝',
cancelled: '已取消',
}
return labels[status] || status || '-'
}
function resolveFinanceRequestStatusColor(status: string) {
if (status === 'approved') return 'green'
if (status === 'pending') return 'gold'
if (status === 'rejected') return 'red'
if (status === 'cancelled') return 'default'
return 'blue'
}
function renderFinanceRequestAccount(request: WorkerFinanceRequest) {
return (
<div className="cell-stack">
<Typography.Text>{renderFinanceRequestAccountText(request)}</Typography.Text>
{request.requestType === 'withdraw' && request.accountNo ? (
<Typography.Text copyable>{request.accountNo}</Typography.Text>
) : null}
</div>
)
}
function renderFinanceRequestAccountText(request: WorkerFinanceRequest) {
if (request.requestType === 'withdraw') {
const channel = formatFinanceChannel(request.accountChannel)
const accountName = request.accountName || '-'
return `${channel} / ${accountName}`
}
const rechargeSnapshot = getRechargeSnapshot(request)
if (rechargeSnapshot.channelName || rechargeSnapshot.accountName || rechargeSnapshot.accountNo) {
return [
rechargeSnapshot.channelName,
rechargeSnapshot.accountName,
rechargeSnapshot.accountNo ? maskFinanceAccount(rechargeSnapshot.accountNo) : '',
]
.filter(Boolean)
.join(' / ')
}
return '线下充值,等待管理员核对'
}
function getRechargeSnapshot(request: WorkerFinanceRequest) {
const payload = asRecord(request.payload)
const financeConfig = asRecord(payload.financeConfig)
return {
channelName: String(financeConfig.channelName || '').trim(),
accountName: String(financeConfig.accountName || '').trim(),
accountNo: String(financeConfig.accountNo || '').trim(),
}
}
function getRechargeProofs(request: WorkerFinanceRequest): UploadedFile[] {
if (request.requestType !== 'recharge') return []
const payload = asRecord(request.payload)
const rawFiles = Array.isArray(payload.proofFiles) ? payload.proofFiles : []
return rawFiles
.map((item) => {
const file = asRecord(item)
const url = String(file.url || '').trim()
if (!url) return null
return {
url,
mediumUrl: String(file.mediumUrl || '').trim(),
thumbnailUrl: String(file.thumbnailUrl || '').trim(),
} as UploadedFile
})
.filter((item): item is UploadedFile => Boolean(item))
}
function getWechatWithdrawQrCode(request: WorkerFinanceRequest): UploadedFile[] {
if (request.requestType !== 'withdraw' || request.accountChannel !== 'wechat') return []
const payload = asRecord(request.payload)
const file = asRecord(payload.wechatQrCodeImage)
const url = String(file.url || '').trim()
if (!url) return []
return [
{
url,
mediumUrl: String(file.mediumUrl || '').trim(),
thumbnailUrl: String(file.thumbnailUrl || '').trim(),
objectKey: String(file.objectKey || '').trim(),
filename: String(file.filename || '').trim(),
contentType: String(file.contentType || '').trim(),
size: Number(file.size || 0),
},
]
}
function getWithdrawQrCode(request: WorkerFinanceRequest): UploadedFile[] {
if (request.accountChannel === 'alipay') return getAlipayWithdrawQrCode(request)
return getWechatWithdrawQrCode(request)
}
function getAlipayWithdrawQrCode(request: WorkerFinanceRequest): UploadedFile[] {
if (request.requestType !== 'withdraw' || request.accountChannel !== 'alipay') return []
const payload = asRecord(request.payload)
const file = asRecord(payload.alipayQrCodeImage)
const url = String(file.url || '').trim()
if (!url) return []
return [
{
url,
mediumUrl: String(file.mediumUrl || '').trim(),
thumbnailUrl: String(file.thumbnailUrl || '').trim(),
objectKey: String(file.objectKey || '').trim(),
filename: String(file.filename || '').trim(),
contentType: String(file.contentType || '').trim(),
size: Number(file.size || 0),
},
]
}
function getRechargePaidAt(request: WorkerFinanceRequest): string {
const payload = asRecord(request.payload)
return String(payload.paidAt || '').trim()
}
function formatFinanceChannel(channel: string) {
if (channel === 'alipay') return '支付宝'
if (channel === 'wechat') return '微信收款'
if (channel === 'bank') return '银行卡'
if (channel === 'manual') return '线下转账'
return channel || '-'
}
function maskFinanceAccount(value: string) {
const text = String(value || '').trim()
if (!text) return '-'
if (text.length <= 8) return text
return `${text.slice(0, 4)} **** ${text.slice(-4)}`
}
type FinanceSummaryChannel = { channel: string; count: number; amount: number }
/** 当前筛选条件下的渠道合计条:支付宝 / 微信 / 其他分列展示笔数与金额。 */
function FinanceSummaryBar({
channels,
totalCount,
totalAmount,
}: {
channels?: FinanceSummaryChannel[]
totalCount?: number
totalAmount?: number
}) {
const items = channels || []
const alipay = items.find((item) => item.channel === 'alipay')
const wechat = items.find((item) => item.channel === 'wechat')
const others = items.filter((item) => item.channel !== 'alipay' && item.channel !== 'wechat')
const otherCount = others.reduce((sum, item) => sum + item.count, 0)
const otherAmount = others.reduce((sum, item) => sum + item.amount, 0)
return (
<Space size={24} wrap style={{ marginBottom: 12 }}>
<span className="finance-summary-item">
支付宝 <strong>{alipay ? alipay.count : 0}</strong> /{' '}
<strong>{formatMoney(alipay ? alipay.amount : 0)}</strong>
</span>
<span className="finance-summary-item">
微信 <strong>{wechat ? wechat.count : 0}</strong> /{' '}
<strong>{formatMoney(wechat ? wechat.amount : 0)}</strong>
</span>
<span className="finance-summary-item">
其他 <strong>{otherCount}</strong> / <strong>{formatMoney(otherAmount)}</strong>
</span>
<span className="finance-summary-item">
合计 <strong>{totalCount || 0}</strong> /{' '}
<strong>{formatMoney(totalAmount || 0)}</strong>
</span>
<Typography.Text type="secondary">按当前筛选条件(含时间区间)统计</Typography.Text>
</Space>
)
}
function resolveFinanceReviewer(request: WorkerFinanceRequest): string {
const review = asRecord(asRecord(request.payload).review)
return String(review.by || '').trim()
}