Files
affiliate_dash/frontend/src/components/ImageUploader.tsx
T
yml2213 941e7ad79a 积分充值全流程落地:申请审核入账、凭证上传压缩转webp、管理员调账收权
- 新增积分充值申请与审核:商户提交充值申请(凭证图片必填,1元=100积分),
  管理员审核通过后事务内自动入账,幂等键防重复
- 新增通用图片上传组件 ImageUploader:canvas 压缩转 webp、预览、删除,
  后端按魔数校验图片格式并持久化到 data/uploads
- 管理员钱包调整改为按商户维度:移除商户侧调账接口,新增管理员
  平台商户积分调整与钱包流水查看
- 低余额 Webhook 告警配置持久化,充值入账/调账后自动检查推送
- 平台商户操作列移除进入/成员,积分充值页接入真实接口并修复金额单位换算
2026-08-04 13:03:29 +08:00

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>
)
}