868 lines
30 KiB
TypeScript
868 lines
30 KiB
TypeScript
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()
|
||
}
|