casualroom/apps/fenghuo/web/components/common/avatar-upload.tsx

172 lines
5.8 KiB
TypeScript
Executable File

'use client';
import React, { useState, useRef, useCallback, use } from 'react';
import { useTusUpload, UploadMetadata } from '@/hooks/use-tus-upload';
import { cn } from '@nice/ui/lib/utils';
import { Upload, Trash2 } from 'lucide-react';
import { toast } from '@nice/ui/components/sonner';
export interface AvatarUploadProps {
className?: string;
value?: string;
onChange?: (url: string) => void;
accept?: string;
maxSize?: number;
disabled?: boolean;
placeholder?: string;
metadata?: UploadMetadata;
}
export function AvatarUpload({
className,
value,
onChange,
accept = 'image/*',
maxSize = 10 * 1024 * 1024,
disabled = false,
placeholder = '点击上传图片',
metadata = {},
}: AvatarUploadProps) {
const [preview, setPreview] = useState<string | null>(value || null);
const fileInputRef = useRef<HTMLInputElement>(null);
React.useEffect(() => {
setPreview(value || null);
}, [value]);
const {
state,
uploadFile,
reset,
isUploading,
isSuccess,
isError,
isPaused,
} = useTusUpload(metadata);
const validateFile = useCallback((file: File): string | null => {
if (!file.type.startsWith('image/')) {
return '请选择图片文件';
}
if (file.size > maxSize) {
return `文件大小不能超过 ${Math.round(maxSize / (1024 * 1024))}MB`;
}
return null;
}, [maxSize]);
const handleFileSelect = useCallback((file: File) => {
const error = validateFile(file);
if (error) {
toast.error(error);
return;
}
const reader = new FileReader();
reader.onload = (e) => {
setPreview(e.target?.result as string);
};
reader.readAsDataURL(file);
// 重置之前的状态
reset();
uploadFile(file, metadata);
}, [validateFile, uploadFile, metadata, reset]);
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
handleFileSelect(file);
}
}, [handleFileSelect]);
const handleClick = useCallback(() => {
if (!disabled && !isUploading && !isPaused) {
fileInputRef.current?.click();
}
}, [disabled, isUploading, isPaused]);
React.useEffect(() => {
if (isSuccess && state.uploadUrl) {
toast.success('图片上传成功');
onChange?.(state.uploadUrl);
}
}, [isSuccess, state.uploadUrl, onChange]);
React.useEffect(() => {
if (isError && state.error) {
toast.error(`上传失败: ${state.error}`);
}
}, [isError, state.error]);
return (
<div className={cn('relative w-24 h-32', className)}>
<input
ref={fileInputRef}
type="file"
accept={accept}
onChange={handleInputChange}
className="hidden"
disabled={disabled}
/>
<div
onClick={handleClick}
className={cn(
'relative border border-gray-300 rounded-md transition-all',
'flex items-center justify-center h-32 w-full',
{
'cursor-not-allowed bg-gray-100': disabled,
'cursor-pointer hover:border-gray-400': !disabled && !isUploading,
}
)}
>
{preview ? (
<div className="relative w-full h-full group">
<img
src={preview}
alt="预览"
className="w-full h-full object-contain"
/>
{!isUploading && (
<button
onClick={(e) => {
e.stopPropagation();
setPreview(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
onChange?.('');
}}
className="absolute cursor-pointer top-1 right-1 w-5 h-5 rounded-full flex items-center justify-center text-sm opacity-0 transition-opacity group-hover:opacity-100"
>
<Trash2 className="w-3 h-3" />
</button>
)}
</div>
) : (
<div className="flex flex-col items-center text-gray-500">
<Upload className="w-4 h-4 mb-2" />
<p className="text-xs">{placeholder}</p>
</div>
)}
{(isUploading || isPaused) && (
<div className="absolute inset-0 bg-black bg-opacity-50 flex flex-col items-center justify-center rounded-md">
{/* 进度条 */}
<div className="w-16 h-1 bg-gray-300 rounded-full mb-2">
<div
className="h-full bg-white rounded-full transition-all duration-300"
style={{ width: `${state.progress}%` }}
/>
</div>
{/* 进度文字 */}
<div className="text-white text-xs mb-2">
{state.progress}%
</div>
</div>
)}
</div>
</div>
);
}
export default AvatarUpload;