iloom-flatten/src/components/ImageUploader.tsx

221 lines
6.0 KiB
TypeScript
Raw Normal View History

import React, { useRef, useState } from 'react';
import { motion } from 'framer-motion';
import { Camera, X, Upload, ImageIcon } from 'lucide-react';
import { http } from '../api';
export type UploadBucket = 'avatars' | 'product_images';
interface ImageUploaderProps {
/** 当前图片 URL */
value?: string | null;
/** 图片变更回调 */
onChange: (url: string | null) => void;
/** 存储桶名称 */
bucket?: UploadBucket;
/** 上传路径前缀(仅 product_images 使用) */
pathPrefix?: string;
/** 是否圆形裁剪(头像模式) */
circular?: boolean;
/** 容器尺寸px */
size?: number;
/** 占位图标 */
placeholderIcon?: React.ReactNode;
/** 是否禁用 */
disabled?: boolean;
/** 自定义类名 */
className?: string;
/** 最大文件大小MB */
maxFileSize?: number;
}
export function ImageUploader({
value,
onChange,
bucket = 'product_images',
pathPrefix = 'products',
circular = false,
size = 80,
placeholderIcon,
disabled = false,
className = '',
maxFileSize = 5
}: ImageUploaderProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [previewUrl, setPreviewUrl] = useState<string | null>(value || null);
// 同步外部 value 变化
React.useEffect(() => {
setPreviewUrl(value || null);
}, [value]);
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// 验证文件类型
if (!file.type.startsWith('image/')) {
alert('请选择图片文件');
return;
}
// 验证文件大小
if (file.size > maxFileSize * 1024 * 1024) {
alert(`图片大小不能超过 ${maxFileSize}MB`);
return;
}
setUploading(true);
try {
const formDataObj = new FormData();
formDataObj.append('file', file);
formDataObj.append('bucket', bucket);
formDataObj.append('path_prefix', pathPrefix);
// TODO: 文件上传接口待后端实现,当前使用 fetch 直接发送 FormData
const res = await fetch('/api/v1/common/upload', {
method: 'POST',
body: formDataObj,
});
if (!res.ok) {
alert('图片上传失败');
setUploading(false);
return;
}
const json = await res.json();
const publicUrl = json.data?.url || '';
setPreviewUrl(publicUrl);
onChange(publicUrl);
setUploading(false);
} catch (error) {
alert('图片上传失败');
setUploading(false);
}
};
const handleRemove = (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
setPreviewUrl(null);
onChange(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const triggerFileSelect = () => {
if (!disabled && !uploading) {
fileInputRef.current?.click();
}
};
const borderRadius = circular ? 'rounded-full' : 'rounded-xl';
const containerStyle = { width: size, height: size };
return (
<div className={`relative inline-block ${className}`}>
<label
className={`${borderRadius} flex items-center justify-center overflow-hidden relative group cursor-pointer transition-all ${disabled || uploading ? 'opacity-50 cursor-not-allowed' : 'hover:shadow-lg'}`}
style={containerStyle}
>
{/* 背景色 */}
<div className="absolute inset-0 bg-gradient-to-br from-gray-100 to-gray-200" />
{/* 图片预览 */}
{previewUrl ? (
<img
src={previewUrl}
alt="预览"
className={`w-full h-full object-cover relative z-10 ${circular ? 'rounded-full' : ''}`}
/>
) : (
<div className="relative z-10 flex flex-col items-center justify-center text-gray-400">
{placeholderIcon || <ImageIcon className="w-8 h-8" />}
</div>
)}
{/* 上传遮罩层 */}
{!disabled && !uploading && (
<div className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-20">
{previewUrl ? (
<Camera className="w-6 h-6 text-white" />
) : (
<Upload className="w-6 h-6 text-white" />
)}
</div>
)}
{/* 上传中状态 */}
{uploading && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center z-30">
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
className="w-6 h-6 border-2 border-white border-t-transparent rounded-full"
/>
</div>
)}
{/* 隐藏的文件输入 */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileSelect}
disabled={disabled || uploading}
/>
</label>
{/* 删除按钮 */}
{previewUrl && !disabled && !uploading && (
<button
onClick={handleRemove}
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 hover:bg-red-600 text-white rounded-full flex items-center justify-center shadow-md transition-colors z-30"
title="删除图片"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
);
}
/**
*
*/
interface AvatarUploaderProps {
value?: string | null;
onChange: (url: string | null) => void;
size?: number;
placeholderIcon?: React.ReactNode;
disabled?: boolean;
className?: string;
}
export function AvatarUploader({
value,
onChange,
size = 64,
placeholderIcon,
disabled = false,
className = ''
}: AvatarUploaderProps) {
return (
<ImageUploader
value={value}
onChange={onChange}
bucket="avatars"
circular
size={size}
placeholderIcon={placeholderIcon}
disabled={disabled}
className={className}
maxFileSize={5}
/>
);
}