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