积分充值全流程落地:申请审核入账、凭证上传压缩转webp、管理员调账收权
- 新增积分充值申请与审核:商户提交充值申请(凭证图片必填,1元=100积分), 管理员审核通过后事务内自动入账,幂等键防重复 - 新增通用图片上传组件 ImageUploader:canvas 压缩转 webp、预览、删除, 后端按魔数校验图片格式并持久化到 data/uploads - 管理员钱包调整改为按商户维度:移除商户侧调账接口,新增管理员 平台商户积分调整与钱包流水查看 - 低余额 Webhook 告警配置持久化,充值入账/调账后自动检查推送 - 平台商户操作列移除进入/成员,积分充值页接入真实接口并修复金额单位换算
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user