237 lines
6.7 KiB
TypeScript
237 lines
6.7 KiB
TypeScript
|
|
import React, { useRef, useState } from 'react';
|
|||
|
|
import { motion } from 'framer-motion';
|
|||
|
|
import { Camera, X, Upload, ImageIcon } from 'lucide-react';
|
|||
|
|
import { supabase } from '../supabase/client';
|
|||
|
|
import { decode } from 'base64-arraybuffer';
|
|||
|
|
|
|||
|
|
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 {
|
|||
|
|
// 读取文件为 base64
|
|||
|
|
const reader = new FileReader();
|
|||
|
|
reader.onload = async (event) => {
|
|||
|
|
const base64 = (event.target?.result as string).split(',')[1];
|
|||
|
|
|
|||
|
|
// 生成唯一文件名
|
|||
|
|
const fileExt = file.name.split('.').pop();
|
|||
|
|
const fileName = bucket === 'avatars'
|
|||
|
|
? `${Date.now()}_${Math.random().toString(36).substring(2, 9)}.${fileExt}`
|
|||
|
|
: `${pathPrefix}/${Date.now()}_${Math.random().toString(36).substring(2, 9)}.${fileExt}`;
|
|||
|
|
|
|||
|
|
// 上传图片到 Supabase Storage
|
|||
|
|
const { error } = await supabase.storage
|
|||
|
|
.from(bucket)
|
|||
|
|
.upload(fileName, decode(base64), {
|
|||
|
|
contentType: file.type,
|
|||
|
|
upsert: true
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (error) {
|
|||
|
|
console.error('上传失败:', error);
|
|||
|
|
alert('图片上传失败: ' + error.message);
|
|||
|
|
setUploading(false);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 获取图片的公开 URL
|
|||
|
|
const { data: { publicUrl } } = supabase.storage
|
|||
|
|
.from(bucket)
|
|||
|
|
.getPublicUrl(fileName);
|
|||
|
|
|
|||
|
|
setPreviewUrl(publicUrl);
|
|||
|
|
onChange(publicUrl);
|
|||
|
|
setUploading(false);
|
|||
|
|
};
|
|||
|
|
reader.readAsDataURL(file);
|
|||
|
|
} catch (error) {
|
|||
|
|
console.error('上传错误:', 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}
|
|||
|
|
/>
|
|||
|
|
);
|
|||
|
|
}
|