172 lines
5.8 KiB
TypeScript
Executable File
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; |