iloom-flatten/src/components/ImageUploader.tsx
Chever John 1b506edfbf
feat: use authenticated upload API in ImageUploader
- Add uploadFile function to commonApi with Authorization header
- Replace raw fetch with commonApi.uploadFile in ImageUploader

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-30 16:27:17 +08:00

203 lines
5.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useRef, useState } from 'react';
import { motion } from 'framer-motion';
import { Camera, X, Upload, ImageIcon } from 'lucide-react';
import { commonApi } from '../api/common';
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 publicUrl = await commonApi.uploadFile(file, bucket, pathPrefix);
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}
/>
);
}