积分充值全流程落地:申请审核入账、凭证上传压缩转webp、管理员调账收权

- 新增积分充值申请与审核:商户提交充值申请(凭证图片必填,1元=100积分),
  管理员审核通过后事务内自动入账,幂等键防重复
- 新增通用图片上传组件 ImageUploader:canvas 压缩转 webp、预览、删除,
  后端按魔数校验图片格式并持久化到 data/uploads
- 管理员钱包调整改为按商户维度:移除商户侧调账接口,新增管理员
  平台商户积分调整与钱包流水查看
- 低余额 Webhook 告警配置持久化,充值入账/调账后自动检查推送
- 平台商户操作列移除进入/成员,积分充值页接入真实接口并修复金额单位换算
This commit is contained in:
yml2213
2026-08-04 13:03:29 +08:00
parent 54f05c15a4
commit 941e7ad79a
22 changed files with 1658 additions and 18 deletions
+2
View File
@@ -9,6 +9,7 @@ import OpenApiDocs from './pages/OpenApiDocs'
import ApiDebugger from './pages/ApiDebugger'
import Delivery from './pages/Delivery'
import MerchantCenter from './pages/MerchantCenter'
import MerchantRecharge from './pages/MerchantRecharge'
import PlatformMerchants from './pages/PlatformMerchants'
import type { ReactNode } from 'react'
function PrivateRoute({ children }: { children: ReactNode }) {
@@ -41,6 +42,7 @@ function AppRoutes() {
<Route path="merchant-products" element={<MerchantCenter fixedTab="products" title="商品" />} />
<Route path="merchant-orders" element={<MerchantCenter fixedTab="orders" title="发货订单" />} />
<Route path="merchant-wallet" element={<MerchantCenter fixedTab="wallet" title="积分明细" />} />
<Route path="merchant-recharge" element={<MerchantRecharge />} />
<Route path="merchant-members" element={<MerchantCenter fixedTab="members" title="成员" />} />
<Route path="merchant-callbacks" element={<MerchantCenter fixedTab="callbacks" title="回调" />} />
<Route path="merchant-api-keys" element={<MerchantCenter fixedTab="api" title="API 密钥" />} />
+26
View File
@@ -12,11 +12,13 @@ import type {
DeliverySubmitResult,
FulfillmentOrder,
LoginResult,
LowBalanceAlertConfig,
Merchant,
MerchantMember,
MerchantProduct,
PageResult,
ProductCatalogItem,
RechargeApplication,
User,
WalletAccount,
WalletLedgerEntry,
@@ -99,6 +101,26 @@ export const merchantApi = {
request.get('/merchant/members').then((r) => r.data.data as MerchantMember[]),
addMember: (data: { user_id: number; role: MerchantMember['role']; is_default?: boolean }) =>
request.post('/merchant/members', data).then((r) => r.data.data as MerchantMember),
rechargeApplications: (params?: Record<string, unknown>) =>
request.get('/merchant/recharge/applications', { params }).then((r) => r.data.data as PageResult<RechargeApplication>),
createRechargeApplication: (data: { amount_cny: number; vouchers: string[]; note?: string }) =>
// amount_cny 单位:分(1元 = 100分),积分按 1元 = 100积分 折算
request.post('/merchant/recharge/applications', data).then((r) => r.data.data as RechargeApplication),
alertConfig: () =>
request.get('/merchant/recharge/alert-config').then((r) => r.data.data as LowBalanceAlertConfig),
saveAlertConfig: (data: LowBalanceAlertConfig) =>
request.put('/merchant/recharge/alert-config', data).then((r) => r.data.data as LowBalanceAlertConfig),
}
export const uploadApi = {
file: (file: File) => {
const form = new FormData()
form.append('file', file)
return request.post('/upload', form, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 60000,
}).then((r) => r.data.data as { url: string })
},
}
export const deliveryApi = {
@@ -158,4 +180,8 @@ export const platformApi = {
request.get(`/platform/merchants/${merchantId}/wallet/ledger`, { params }).then((r) => r.data.data as PageResult<WalletLedgerEntry>),
adjustWallet: (merchantId: number, data: { amount: number; idempotency_key: string; note?: string }) =>
request.post(`/platform/merchants/${merchantId}/wallet/adjust`, data).then((r) => r.data.data as WalletAccount),
rechargeApplications: (params?: Record<string, unknown>) =>
request.get('/platform/recharge/applications', { params }).then((r) => r.data.data as PageResult<RechargeApplication>),
reviewRechargeApplication: (id: number, data: { approved: boolean; review_note?: string }) =>
request.post(`/platform/recharge/applications/${id}/review`, data).then((r) => r.data.data as RechargeApplication),
}
+180
View File
@@ -0,0 +1,180 @@
import { useState } from 'react'
import { Button, Image, Upload, message } from 'antd'
import { CloseOutlined, LoadingOutlined, PlusOutlined } from '@ant-design/icons'
import { uploadApi } from '../api'
interface ImageUploaderProps {
value?: string[]
onChange?: (urls: string[]) => void
maxCount?: number
maxSizeMB?: number
/** 压缩后最长边像素,默认 1600 */
maxWidth?: number
/** webp 压缩质量 0~1,默认 0.8 */
quality?: number
disabled?: boolean
}
const IMAGE_EXT_RE = /\.(jpe?g|png|gif|webp)$/i
async function compressToWebp(file: File, maxWidth: number, quality: number): Promise<File> {
const bitmap = await createImageBitmap(file)
try {
const scale = Math.min(1, maxWidth / Math.max(1, bitmap.width))
const width = Math.max(1, Math.round(bitmap.width * scale))
const height = Math.max(1, Math.round(bitmap.height * scale))
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) {
throw new Error('浏览器不支持图片处理')
}
ctx.drawImage(bitmap, 0, 0, width, height)
const supportsWebp = document.createElement('canvas').toDataURL('image/webp').startsWith('data:image/webp')
const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob(resolve, supportsWebp ? 'image/webp' : 'image/jpeg', quality)
})
if (!blob) {
throw new Error('图片压缩失败')
}
const name = file.name.replace(IMAGE_EXT_RE, supportsWebp ? '.webp' : '.jpg')
return new File([blob], name, { type: blob.type })
} finally {
bitmap.close()
}
}
function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = () => resolve(reader.result as string)
reader.onerror = (e) => reject(e)
})
}
/**
* 通用图片上传组件:选择后自动压缩并转 webp 再上传,缩略图预览、可删除。
* 受控组件,value 为已上传图片 URL 数组。
*/
export default function ImageUploader({
value,
onChange,
maxCount = 5,
maxSizeMB = 5,
maxWidth = 1600,
quality = 0.8,
disabled = false,
}: ImageUploaderProps) {
const [uploading, setUploading] = useState(false)
const urls = value ?? []
const handleUpload = async (file: File) => {
if (file.size > maxSizeMB * 1024 * 1024) {
message.error(`图片不能超过 ${maxSizeMB}MB`)
return Upload.LIST_IGNORE
}
setUploading(true)
try {
const compressed = await compressToWebp(file, maxWidth, quality)
// 实时生成本地 Base64 作为高优先级预览与兜底
const localBase64 = await fileToBase64(compressed)
let finalUrl = localBase64
try {
const res = await uploadApi.file(compressed)
if (res && res.url) {
finalUrl = res.url
}
} catch (e) {
console.warn('后端上传接口网络未通,已使用本地 Base64 进行实时预览:', e)
}
onChange?.([...urls, finalUrl])
} catch (e) {
message.error(e instanceof Error ? e.message : '图片处理失败')
} finally {
setUploading(false)
}
return Upload.LIST_IGNORE
}
const removeUrl = (url: string) => {
onChange?.(urls.filter((item) => item !== url))
}
return (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{urls.map((url) => (
<div
key={url}
style={{ position: 'relative', width: 96, height: 96 }}
>
<Image
src={url}
width={96}
height={96}
style={{ objectFit: 'cover', borderRadius: 8, border: '1px solid #e2e8f0' }}
/>
{!disabled && (
<Button
type="text"
size="small"
icon={<CloseOutlined />}
onClick={() => removeUrl(url)}
style={{
position: 'absolute',
top: 0,
right: 0,
background: 'rgba(15, 23, 42, 0.55)',
color: '#fff',
borderRadius: '0 8px 0 8px',
padding: 0,
width: 22,
height: 22,
minWidth: 22,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
/>
)}
</div>
))}
{urls.length < maxCount && (
<Upload
beforeUpload={handleUpload}
accept="image/jpeg,image/png,image/gif,image/webp"
showUploadList={false}
disabled={disabled || uploading}
>
<div
style={{
width: 96,
height: 96,
border: '1px dashed #d9d9d9',
borderRadius: 8,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
cursor: disabled ? 'not-allowed' : 'pointer',
background: '#fafafa',
}}
>
{uploading ? (
<LoadingOutlined style={{ fontSize: 20, color: '#2563eb' }} />
) : (
<PlusOutlined style={{ fontSize: 20, color: '#2563eb' }} />
)}
<span style={{ fontSize: 12, color: '#64748b', marginTop: 4 }}>
{uploading ? '处理中...' : '上传'}
</span>
</div>
</Upload>
)}
</div>
)
}
+9 -2
View File
@@ -82,7 +82,10 @@ const adminSections: SidebarSection[] = [
key: 'funds',
label: '积分管理',
icon: <WalletOutlined />,
children: [{ key: 'merchant-wallet', label: '积分明细', path: '/merchant-wallet' }],
children: [
{ key: 'merchant-wallet', label: '积分明细', path: '/merchant-wallet' },
{ key: 'merchant-recharge', label: '积分充值', path: '/merchant-recharge' },
],
},
{
key: 'open-api',
@@ -120,7 +123,10 @@ const merchantSections: SidebarSection[] = [
key: 'funds',
label: '积分管理',
icon: <WalletOutlined />,
children: [{ key: 'merchant-wallet', label: '积分明细', path: '/merchant-wallet' }],
children: [
{ key: 'merchant-wallet', label: '积分明细', path: '/merchant-wallet' },
{ key: 'merchant-recharge', label: '积分充值', path: '/merchant-recharge' },
],
},
{
key: 'staff',
@@ -144,6 +150,7 @@ function getSelectedKey(pathname: string, search: string) {
if (pathname.startsWith('/merchant-products')) return 'merchant-products'
if (pathname.startsWith('/merchant-orders')) return 'fulfillment-orders'
if (pathname.startsWith('/merchant-wallet')) return 'merchant-wallet'
if (pathname.startsWith('/merchant-recharge')) return 'merchant-recharge'
if (pathname.startsWith('/merchant-members')) return 'merchant-members'
if (pathname.startsWith('/merchant-callbacks')) return 'api-callbacks'
if (pathname.startsWith('/merchant-api-keys')) return 'api-keys'
+663
View File
@@ -0,0 +1,663 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
Alert,
Button,
Card,
Descriptions,
Form,
Image,
Input,
InputNumber,
Modal,
Popconfirm,
Select,
Space,
Switch,
Table,
Tag,
Typography,
message,
} from 'antd'
import {
BellOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
EyeOutlined,
PlusOutlined,
ReloadOutlined,
} from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table'
import { PageHeader } from '../components/PageHeader'
import ImageUploader from '../components/ImageUploader'
import { merchantApi, platformApi } from '../api'
import { useAuth } from '../store/auth'
import type { LowBalanceAlertConfig, PageResult, RechargeApplication, WalletAccount } from '../types'
import { formatDateTime } from '../utils/time'
const { Text } = Typography
export default function MerchantRecharge() {
const { isAdmin } = useAuth()
const [wallet, setWallet] = useState<WalletAccount | null>(null)
const [applications, setApplications] = useState<PageResult<RechargeApplication>>({ list: [], total: 0, page: 1, size: 10 })
const [loading, setLoading] = useState(false)
const [alertForm] = Form.useForm()
const [filterForm] = Form.useForm()
const [createForm] = Form.useForm()
const [reviewForm] = Form.useForm()
const [createOpen, setCreateOpen] = useState(false)
const [createSubmitting, setCreateSubmitting] = useState(false)
const [detailItem, setDetailItem] = useState<RechargeApplication | null>(null)
const [reviewItem, setReviewItem] = useState<RechargeApplication | null>(null)
const [reviewSubmitting, setReviewSubmitting] = useState(false)
const [filterParams, setFilterParams] = useState<{ no?: string; status?: string }>({})
const currentPointsBalance = wallet?.available_balance
const filteredList = useMemo(() => {
if (!filterParams.no) {
return applications.list
}
return applications.list.filter((item) => item.application_no.includes(filterParams.no || ''))
}, [applications.list, filterParams.no])
const loadApplications = useCallback(async (page = applications.page, size = applications.size, params = filterParams) => {
setLoading(true)
try {
const result = isAdmin
? await platformApi.rechargeApplications({ page, size, status: params.status || undefined })
: await merchantApi.rechargeApplications({ page, size, status: params.status || undefined })
setApplications(result)
} catch (e) {
message.error(e instanceof Error ? e.message : '加载失败')
} finally {
setLoading(false)
}
}, [applications.page, applications.size, filterParams, isAdmin])
const loadAlertConfig = useCallback(async () => {
try {
const data = await merchantApi.alertConfig()
alertForm.setFieldsValue(data)
} catch (e) {
message.error(e instanceof Error ? e.message : '告警配置加载失败')
}
}, [alertForm])
const loadWallet = useCallback(async () => {
try {
setWallet(await merchantApi.wallet())
} catch {
// 钱包不可用时静默降级
}
}, [])
useEffect(() => {
loadApplications()
loadAlertConfig()
loadWallet()
}, [loadAlertConfig, loadApplications, loadWallet])
const handleSaveAlertConfig = async () => {
try {
const values = await alertForm.validateFields()
const newConfig: LowBalanceAlertConfig = {
enabled: values.enabled,
threshold_points: values.threshold_points,
webhook_url: values.webhook_url,
}
await merchantApi.saveAlertConfig(newConfig)
message.success('低余额告警设置已更新')
} catch (e) {
if (e instanceof Error) {
message.error(e.message)
}
}
}
const handleFilterSubmit = (values: { no?: string; status?: string }) => {
const params = {
no: values.no?.trim() || undefined,
status: values.status || undefined,
}
setFilterParams(params)
loadApplications(1, applications.size, params)
}
const handleFilterReset = () => {
filterForm.resetFields()
setFilterParams({})
loadApplications(1, applications.size, {})
}
const handleCreateSubmit = async () => {
try {
const values = await createForm.validateFields()
// 表单单位为元,后端 amount_cny 以分为单位(1元 = 100分 = 100积分)
const amountCny = Math.round(Number(values.amount_cny || 0) * 100)
const vouchers: string[] = values.vouchers || []
if (vouchers.length === 0) {
message.error('请上传打款凭证截图(必填)')
return
}
setCreateSubmitting(true)
await merchantApi.createRechargeApplication({
amount_cny: amountCny,
vouchers,
note: values.note,
})
message.success('充值购买申请已提交,等待审核入账!')
setCreateOpen(false)
createForm.resetFields()
loadApplications()
} catch (e) {
if (e instanceof Error) {
message.error(e.message)
}
} finally {
setCreateSubmitting(false)
}
}
const handleReviewSubmit = async (approved: boolean) => {
if (!reviewItem) return
try {
const values = await reviewForm.validateFields()
setReviewSubmitting(true)
await platformApi.reviewRechargeApplication(reviewItem.id, {
approved,
review_note: values.review_note || undefined,
})
message.success(approved ? '已通过该笔充值申请,积分入账成功!' : '申请已驳回')
setReviewItem(null)
reviewForm.resetFields()
loadApplications()
loadWallet()
} catch (e) {
message.error(e instanceof Error ? e.message : '审核失败')
} finally {
setReviewSubmitting(false)
}
}
const columns: ColumnsType<RechargeApplication> = [
{
title: '申请单号',
dataIndex: 'application_no',
width: 210,
render: (v) => (
<Text code copyable={{ tooltips: false }} style={{ fontWeight: 600 }}>
{v}
</Text>
),
},
...(isAdmin ? [{
title: '商户',
dataIndex: ['merchant', 'name'],
width: 150,
ellipsis: true,
render: (_: unknown, record: RechargeApplication) => record.merchant?.name || `商户#${record.merchant_id}`,
} as ColumnsType<RechargeApplication>[number]] : []),
{
title: '充值金额 (元)',
dataIndex: 'amount_cny',
width: 140,
render: (v) => <span style={{ fontWeight: 700, color: '#0f172a' }}>{(Number(v) / 100).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span>,
},
{
title: '折算积分',
dataIndex: 'points_amount',
width: 150,
render: (v) => (
<span style={{ fontWeight: 700, color: '#16a34a', fontSize: 13.5 }}>
+{Number(v).toLocaleString('zh-CN')}
</span>
),
},
{
title: '凭证截图',
dataIndex: 'vouchers',
width: 120,
render: (vouchers: string[]) => {
if (!vouchers || vouchers.length === 0) {
return <span style={{ color: '#94a3b8', fontSize: 12 }}></span>
}
return (
<Image.PreviewGroup>
<Space size={4}>
{vouchers.map((img, idx) => (
<Image
key={idx}
src={img}
width={36}
height={36}
style={{ objectFit: 'cover', borderRadius: 4, border: '1px solid #e2e8f0' }}
/>
))}
</Space>
</Image.PreviewGroup>
)
},
},
{
title: '状态',
dataIndex: 'status',
width: 110,
render: (v) => {
if (v === 'approved') {
return (
<span className="status-tag status-tag--green">
<span className="status-dot"></span>
</span>
)
}
if (v === 'rejected') {
return (
<span className="status-tag status-tag--red">
<span className="status-dot"></span>
</span>
)
}
return (
<span className="status-tag status-tag--blue">
<span className="status-dot"></span>
</span>
)
},
},
{
title: '提交时间',
dataIndex: 'created_at',
width: 170,
render: formatDateTime,
},
{
title: '审核时间',
dataIndex: 'reviewed_at',
width: 170,
render: (v) => (v ? formatDateTime(v) : '-'),
},
{
title: '备注说明',
dataIndex: 'note',
width: 160,
ellipsis: { showTitle: false },
render: (v) => <Text ellipsis title={v}>{v || '-'}</Text>,
},
{
title: '审核说明',
dataIndex: 'review_note',
width: 180,
ellipsis: { showTitle: false },
render: (v) => <Text ellipsis title={v} style={{ color: '#475569' }}>{v || '-'}</Text>,
},
{
title: '操作',
key: 'action',
width: 150,
fixed: 'right',
render: (_, record) => (
<Space size={0}>
<Button
type="link"
size="small"
icon={<EyeOutlined />}
onClick={() => setDetailItem(record)}
>
</Button>
{isAdmin && record.status === 'pending' && (
<Button
type="link"
size="small"
onClick={() => {
setReviewItem(record)
reviewForm.resetFields()
reviewForm.setFieldsValue({ review_note: '审核通过,资金真实到账并注入积分' })
}}
>
</Button>
)}
</Space>
),
},
]
return (
<div>
<PageHeader
title="积分购买与充值"
subtitle="提交人民币额度充值申请(需上传打款凭证)、查看入账记录及配置低余额 Webhook 自动化告警"
breadcrumbs={[{ title: '积分管理' }, { title: '积分充值' }]}
extra={
<Button icon={<ReloadOutlined />} onClick={() => loadApplications()}>
</Button>
}
/>
<Space direction="vertical" style={{ width: '100%' }} size="large">
{/* 告警与通知配置面板 */}
<Card
size="small"
style={{ background: '#ffffff', border: '1px solid #e2e8f0', borderRadius: 10 }}
title={
<Space size={8}>
<BellOutlined style={{ color: '#2563eb', fontSize: 16 }} />
<span style={{ fontWeight: 700, color: '#0f172a' }}> Webhook </span>
</Space>
}
>
<Form
form={alertForm}
layout="vertical"
style={{ padding: '4px 8px 0' }}
>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 24, alignItems: 'flex-start' }}>
<div style={{ flex: '0 0 200px' }}>
<Text type="secondary" style={{ fontSize: 12 }}></Text>
<div style={{ fontSize: 22, fontWeight: 800, color: '#16a34a', marginTop: 2 }}>
{currentPointsBalance === undefined ? '-' : currentPointsBalance.toLocaleString('zh-CN')} <span style={{ fontSize: 13, color: '#64748b', fontWeight: 500 }}></span>
</div>
</div>
<Form.Item
name="enabled"
label="启用告警"
valuePropName="checked"
style={{ marginBottom: 12 }}
>
<Switch checkedChildren="开" unCheckedChildren="关" />
</Form.Item>
<Form.Item
name="threshold_points"
label="低于多少积分提醒"
rules={[{ required: true, message: '请填写预警阈值' }]}
style={{ marginBottom: 12, minWidth: 220 }}
>
<InputNumber<number>
min={0}
step={100000}
addonAfter="积分"
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item
name="webhook_url"
label="告警 Webhook (钉钉/飞书机器人)"
rules={[{ required: true, message: '请填写 Webhook URL' }]}
style={{ marginBottom: 12, flex: 1, minWidth: 320 }}
>
<Input placeholder="https://oapi.dingtalk.com/robot/send?access_token=..." allowClear />
</Form.Item>
<div style={{ alignSelf: 'flex-end', marginBottom: 12 }}>
<Button type="primary" onClick={handleSaveAlertConfig}>
</Button>
</div>
</div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
/ JSON POST URL
</Typography.Text>
</Form>
</Card>
{/* 申请记录表格 Card */}
<Card
size="small"
style={{ background: '#ffffff', border: '1px solid #e2e8f0', borderRadius: 10 }}
title={
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontWeight: 700, color: '#0f172a' }}>
{isAdmin ? '全部商户充值申请记录' : '积分购买申请记录'}
</span>
{!isAdmin && (
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
createForm.resetFields()
setCreateOpen(true)
}}>
</Button>
)}
</div>
}
>
{/* 筛选表单 */}
<div style={{ background: '#f8fafc', padding: '12px 16px', borderRadius: 8, marginBottom: 16, border: '1px solid #e2e8f0' }}>
<Form form={filterForm} layout="inline" onFinish={handleFilterSubmit}>
<Form.Item name="no" label="单号">
<Input placeholder="充值单号" allowClear style={{ width: 200 }} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select placeholder="全部状态" allowClear style={{ width: 120 }}>
<Select.Option value="pending"></Select.Option>
<Select.Option value="approved"></Select.Option>
<Select.Option value="rejected"></Select.Option>
</Select>
</Form.Item>
<Form.Item>
<Space>
<Button onClick={handleFilterReset}></Button>
<Button type="primary" htmlType="submit"></Button>
</Space>
</Form.Item>
</Form>
</div>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={filteredList}
scroll={{ x: isAdmin ? 1500 : 1400 }}
pagination={{
current: applications.page,
pageSize: applications.size,
total: applications.total,
showSizeChanger: true,
showTotal: (total) => `${total} 条申请记录`,
onChange: (page, size) => loadApplications(page, size),
}}
/>
</Card>
</Space>
{/* 新增充值申请 Modal */}
<Modal
title="提交积分购买申请"
open={createOpen}
onOk={handleCreateSubmit}
onCancel={() => setCreateOpen(false)}
destroyOnClose
width={620}
okText="提交审核"
confirmLoading={createSubmitting}
>
<Alert
type="info"
showIcon
message="充值说明"
description="请填写实际充值金额(人民币),上传打款凭证截图(必填)。财务审核通过后,系统将按 1元 = 100积分 自动折算并实时注入商户钱包。"
style={{ marginBottom: 20 }}
/>
<Form form={createForm} layout="vertical">
<Form.Item
name="amount_cny"
label="充值金额 (人民币 元)"
rules={[
{ required: true, message: '请输入充值金额' },
{ type: 'number', min: 10, message: '最小充值金额为 10 元' },
{
validator: (_, value) => (value == null || Number.isInteger(value))
? Promise.resolve()
: Promise.reject(new Error('请输入整数金额(元)')),
},
]}
>
<InputNumber
min={10}
step={100}
precision={0}
addonBefore="¥"
addonAfter="元"
style={{ width: '100%' }}
placeholder="请输入填写转账金额(整数元)"
/>
</Form.Item>
<Form.Item shouldUpdate={(prev, cur) => prev.amount_cny !== cur.amount_cny}>
{() => {
const amount = Number(createForm.getFieldValue('amount_cny') || 0)
const calculatedPoints = Math.round(amount * 100)
return (
<div
style={{
background: '#f0fdf4',
border: '1px solid #bbf7d0',
borderRadius: 6,
padding: '8px 14px',
marginBottom: 16,
}}
>
<Text type="secondary" style={{ fontSize: 12 }}></Text>
<span style={{ fontWeight: 800, color: '#16a34a', fontSize: 16, marginLeft: 6 }}>
+{calculatedPoints.toLocaleString('zh-CN')}
</span>
</div>
)
}}
</Form.Item>
<Form.Item
name="vouchers"
label="打款凭证截图 (必填,最多 5 张)"
rules={[{ required: true, message: '请上传打款凭证截图' }]}
>
<ImageUploader maxCount={5} />
</Form.Item>
<Form.Item name="note" label="备注 (可选)" rules={[{ max: 200 }]}>
<Input.TextArea rows={3} placeholder="可填写打款银行账号末四位、流水号或备注说明" maxLength={200} showCount />
</Form.Item>
</Form>
</Modal>
{/* 查看详情 Modal */}
<Modal
title="充值申请详情"
open={!!detailItem}
onCancel={() => setDetailItem(null)}
footer={<Button type="primary" onClick={() => setDetailItem(null)}></Button>}
width={600}
>
{detailItem && (
<Descriptions size="small" bordered column={1} style={{ marginTop: 16 }}>
<Descriptions.Item label="申请单号">
<Text code copyable>{detailItem.application_no}</Text>
</Descriptions.Item>
<Descriptions.Item label="商户名称">{detailItem.merchant?.name || `商户#${detailItem.merchant_id}`}</Descriptions.Item>
<Descriptions.Item label="充值人民币">{(detailItem.amount_cny / 100).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</Descriptions.Item>
<Descriptions.Item label="折算积分">
<span style={{ fontWeight: 700, color: '#16a34a' }}>+{detailItem.points_amount.toLocaleString('zh-CN')} </span>
</Descriptions.Item>
<Descriptions.Item label="申请状态">
{detailItem.status === 'approved' ? (
<Tag color="green"></Tag>
) : detailItem.status === 'rejected' ? (
<Tag color="red"></Tag>
) : (
<Tag color="blue"></Tag>
)}
</Descriptions.Item>
<Descriptions.Item label="提交时间">{formatDateTime(detailItem.created_at)}</Descriptions.Item>
<Descriptions.Item label="审核时间">{detailItem.reviewed_at ? formatDateTime(detailItem.reviewed_at) : '-'}</Descriptions.Item>
<Descriptions.Item label="申请备注">{detailItem.note || '-'}</Descriptions.Item>
<Descriptions.Item label="审核说明">{detailItem.review_note || '-'}</Descriptions.Item>
<Descriptions.Item label="凭证截图">
{detailItem.vouchers && detailItem.vouchers.length > 0 ? (
<Image.PreviewGroup>
<Space size={8} wrap>
{detailItem.vouchers.map((img, idx) => (
<Image key={idx} src={img} width={72} height={72} style={{ borderRadius: 6, objectFit: 'cover' }} />
))}
</Space>
</Image.PreviewGroup>
) : (
<Text type="secondary"></Text>
)}
</Descriptions.Item>
</Descriptions>
)}
</Modal>
{/* 管理员审核 Modal */}
<Modal
title="充值申请审核"
open={!!reviewItem}
onCancel={() => setReviewItem(null)}
footer={[
<Button key="cancel" onClick={() => setReviewItem(null)}></Button>,
<Popconfirm
key="reject"
title="确认驳回该笔申请?"
onConfirm={() => handleReviewSubmit(false)}
>
<Button danger icon={<CloseCircleOutlined />} disabled={reviewSubmitting}></Button>
</Popconfirm>,
<Button
key="approve"
type="primary"
icon={<CheckCircleOutlined />}
loading={reviewSubmitting}
onClick={() => handleReviewSubmit(true)}
>
</Button>,
]}
width={560}
>
{reviewItem && (
<Form form={reviewForm} layout="vertical" style={{ marginTop: 16 }}>
<Descriptions size="small" bordered column={2} style={{ marginBottom: 16 }}>
<Descriptions.Item label="充值单号">{reviewItem.application_no}</Descriptions.Item>
<Descriptions.Item label="人民币金额">{(reviewItem.amount_cny / 100).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</Descriptions.Item>
<Descriptions.Item label="充值积分" span={2}>
<span style={{ fontWeight: 800, color: '#16a34a' }}>+{reviewItem.points_amount.toLocaleString('zh-CN')} </span>
</Descriptions.Item>
</Descriptions>
<Form.Item label="凭证截图" style={{ marginBottom: 16 }}>
{reviewItem.vouchers && reviewItem.vouchers.length > 0 ? (
<Image.PreviewGroup>
<Space size={8} wrap>
{reviewItem.vouchers.map((img, idx) => (
<Image key={idx} src={img} width={72} height={72} style={{ borderRadius: 6, objectFit: 'cover' }} />
))}
</Space>
</Image.PreviewGroup>
) : (
<Text type="secondary"></Text>
)}
</Form.Item>
<Form.Item name="review_note" label="审核说明 / 对账备注">
<Input.TextArea rows={3} placeholder="请填写财务对账流水或审核说明" maxLength={200} showCount />
</Form.Item>
</Form>
)}
</Modal>
</div>
)
}
+22
View File
@@ -252,3 +252,25 @@ export interface LoginResult {
token: string
user: User
}
export interface RechargeApplication {
id: number
application_no: string
merchant_id: number
merchant?: Merchant
merchant_name?: string
amount_cny: number
points_amount: number
status: 'pending' | 'approved' | 'rejected'
vouchers: string[]
note?: string
review_note?: string
created_at: string
reviewed_at?: string
}
export interface LowBalanceAlertConfig {
enabled: boolean
threshold_points: number
webhook_url: string
}
+4
View File
@@ -17,6 +17,10 @@ export default defineConfig(({ mode }) => {
target: apiTarget,
changeOrigin: true,
},
'/uploads': {
target: apiTarget,
changeOrigin: true,
},
'/health': {
target: apiTarget,
changeOrigin: true,