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

684 lines
23 KiB
TypeScript

import { ReloadOutlined } from '@ant-design/icons'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
App,
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 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 { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
import { formatAdminDateTime } from '@/utils/admin-time'
import { asRecord, formatMoney } from './shared'
type FinanceConfigFormValues = {
depositUnfreezeDays?: number
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 [configForm] = Form.useForm<FinanceConfigFormValues>()
const [reviewForm] = Form.useForm<{ reviewedNote?: string }>()
const [requestStatus, setRequestStatus] = useState('')
const [requestType, setRequestType] = useState('')
const [keywordInput, setKeywordInput] = useState('')
const [keyword, setKeyword] = 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(),
})
const financeRequestsQuery = useQuery({
queryKey: [
'admin-worker-platform-finance-requests',
requestStatus,
requestType,
keyword,
page,
pageSize,
],
queryFn: () =>
fetchAdminWorkerFinanceRequests({
status: requestStatus,
requestType,
keyword,
page,
pageSize,
}),
})
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),
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: 180,
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>
</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) : getWechatWithdrawQrCode(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)}` : '待审核'}
</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">
<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>
<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)
}}
/>
<Button
icon={<ReloadOutlined />}
loading={financeRequestsQuery.isFetching}
onClick={() => financeRequestsQuery.refetch()}
>
刷新
</Button>
</Space>
}
>
<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
confirmLoading={reviewing}
onCancel={() => {
setReviewState(null)
reviewForm.resetFields()
}}
onOk={() => reviewForm.submit()}
>
{reviewState ? (
<Space direction="vertical" size={16} className="full-width">
<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' &&
reviewState.request.accountChannel === 'alipay' ? (
<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>
<Descriptions.Item label="付款凭证">
<ImagePreviewList files={getRechargeProofs(reviewState.request)} size={88} />
</Descriptions.Item>
</>
) : null}
{reviewState.request.requestType === 'withdraw' &&
reviewState.request.accountChannel === 'wechat' ? (
<Descriptions.Item label="微信收款二维码">
<ImagePreviewList
files={getWechatWithdrawQrCode(reviewState.request)}
size={88}
/>
</Descriptions.Item>
) : null}
<Descriptions.Item label="申请备注">
{reviewState.request.note || '-'}
</Descriptions.Item>
</Descriptions>
<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),
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 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)}`
}