|
|
@@ -1,9 +1,6 @@
|
|
|
import React, { useState, useRef } from 'react';
|
|
|
-import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
|
-import { silverUserProfileClient } from '@/client/api';
|
|
|
import { uploadMinIOWithPolicy } from '@/client/utils/minio';
|
|
|
import { compressImage, isImageFile, checkFileSize } from '@/client/utils/upload';
|
|
|
-import { useAuth } from '../hooks/AuthProvider';
|
|
|
import { AvatarCropper } from './AvatarCropper';
|
|
|
|
|
|
// 水墨风格色彩系统
|
|
|
@@ -28,105 +25,25 @@ const COLORS = {
|
|
|
interface AvatarUploadProps {
|
|
|
currentAvatar?: string | null;
|
|
|
onUploadSuccess?: (avatarFileId: number) => void;
|
|
|
+ onUploadError?: (error: Error) => void;
|
|
|
size?: number;
|
|
|
+ userId?: number;
|
|
|
+ username?: string;
|
|
|
}
|
|
|
|
|
|
export const AvatarUpload: React.FC<AvatarUploadProps> = ({
|
|
|
currentAvatar,
|
|
|
onUploadSuccess,
|
|
|
+ onUploadError,
|
|
|
size = 96,
|
|
|
+ userId,
|
|
|
+ username,
|
|
|
}) => {
|
|
|
const [isUploading, setIsUploading] = useState(false);
|
|
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
|
|
const [showCropper, setShowCropper] = useState(false);
|
|
|
const [cropImageUrl, setCropImageUrl] = useState<string | null>(null);
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
- const { user } = useAuth();
|
|
|
- const queryClient = useQueryClient();
|
|
|
-
|
|
|
- // 上传头像的mutation
|
|
|
- const uploadAvatarMutation = useMutation({
|
|
|
- mutationFn: async (file: File) => {
|
|
|
- if (!user?.id) {
|
|
|
- throw new Error('用户未登录');
|
|
|
- }
|
|
|
-
|
|
|
- // 1. 压缩图片
|
|
|
- const compressedFile = await compressImage(file, 800, 800, 0.8);
|
|
|
-
|
|
|
- // 2. 生成上传路径
|
|
|
- const timestamp = Date.now();
|
|
|
- const fileExtension = compressedFile.name.split('.').pop();
|
|
|
- const fileKey = `avatars/${user.id}/${timestamp}.${fileExtension}`;
|
|
|
-
|
|
|
- // 3. 上传到MinIO
|
|
|
- const result = await uploadMinIOWithPolicy(
|
|
|
- 'avatars',
|
|
|
- compressedFile,
|
|
|
- fileKey
|
|
|
- );
|
|
|
-
|
|
|
- // 4. 获取用户资料ID
|
|
|
- const profileResponse = await silverUserProfileClient.$get({
|
|
|
- query: { filters: JSON.stringify({ userId: user.id }) }
|
|
|
- });
|
|
|
-
|
|
|
- let profileId: number;
|
|
|
- if (profileResponse.status === 200) {
|
|
|
- const profileData = await profileResponse.json();
|
|
|
- if (profileData.data && profileData.data.length > 0) {
|
|
|
- profileId = profileData.data[0].id;
|
|
|
- } else {
|
|
|
- // 创建新的用户资料
|
|
|
- const createResponse = await silverUserProfileClient.$post({
|
|
|
- json: {
|
|
|
- realName: user.username || '用户',
|
|
|
- age: 30,
|
|
|
- gender: 1,
|
|
|
- phone: '',
|
|
|
- avatarFileId: result.fileId,
|
|
|
- }
|
|
|
- });
|
|
|
-
|
|
|
- if (createResponse.status !== 201) {
|
|
|
- throw new Error('创建用户资料失败');
|
|
|
- }
|
|
|
-
|
|
|
- const createdProfile = await createResponse.json();
|
|
|
- profileId = createdProfile.id;
|
|
|
- }
|
|
|
- } else {
|
|
|
- throw new Error('获取用户资料失败');
|
|
|
- }
|
|
|
-
|
|
|
- // 5. 更新用户资料头像
|
|
|
- const updateResponse = await silverUserProfileClient[':id'].$put({
|
|
|
- param: { id: profileId },
|
|
|
- json: {
|
|
|
- avatarFileId: result.fileId,
|
|
|
- }
|
|
|
- });
|
|
|
-
|
|
|
- if (updateResponse.status !== 200) {
|
|
|
- throw new Error('更新用户头像失败');
|
|
|
- }
|
|
|
-
|
|
|
- return result.fileId;
|
|
|
- },
|
|
|
- onSuccess: (fileId) => {
|
|
|
- // 清除相关缓存
|
|
|
- queryClient.invalidateQueries({ queryKey: ['userProfile'] });
|
|
|
- queryClient.invalidateQueries({ queryKey: ['currentUser'] });
|
|
|
- queryClient.invalidateQueries({ queryKey: ['silverUserProfile'] });
|
|
|
-
|
|
|
- if (onUploadSuccess) {
|
|
|
- onUploadSuccess(fileId);
|
|
|
- }
|
|
|
- },
|
|
|
- onError: (error) => {
|
|
|
- console.error('头像上传失败:', error);
|
|
|
- }
|
|
|
- });
|
|
|
|
|
|
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
const file = event.target.files?.[0];
|
|
|
@@ -134,13 +51,17 @@ export const AvatarUpload: React.FC<AvatarUploadProps> = ({
|
|
|
|
|
|
// 验证文件类型
|
|
|
if (!isImageFile(file)) {
|
|
|
- alert('请选择图片文件');
|
|
|
+ const error = new Error('请选择图片文件');
|
|
|
+ if (onUploadError) onUploadError(error);
|
|
|
+ else alert(error.message);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// 验证文件大小 (2MB)
|
|
|
if (!checkFileSize(file, 2)) {
|
|
|
- alert('图片大小不能超过2MB');
|
|
|
+ const error = new Error('图片大小不能超过2MB');
|
|
|
+ if (onUploadError) onUploadError(error);
|
|
|
+ else alert(error.message);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
@@ -152,7 +73,9 @@ export const AvatarUpload: React.FC<AvatarUploadProps> = ({
|
|
|
|
|
|
} catch (error) {
|
|
|
console.error('文件处理失败:', error);
|
|
|
- alert('文件处理失败,请重试');
|
|
|
+ const err = error as Error;
|
|
|
+ if (onUploadError) onUploadError(err);
|
|
|
+ else alert('文件处理失败,请重试');
|
|
|
} finally {
|
|
|
// 清除文件输入
|
|
|
if (fileInputRef.current) {
|
|
|
@@ -162,26 +85,47 @@ export const AvatarUpload: React.FC<AvatarUploadProps> = ({
|
|
|
};
|
|
|
|
|
|
const handleCropComplete = async (croppedBlob: Blob) => {
|
|
|
+ if (!userId) {
|
|
|
+ const error = new Error('用户ID未提供');
|
|
|
+ if (onUploadError) onUploadError(error);
|
|
|
+ else alert(error.message);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
try {
|
|
|
setIsUploading(true);
|
|
|
setShowCropper(false);
|
|
|
|
|
|
- // 创建文件对象
|
|
|
- const croppedFile = new File([croppedBlob], 'avatar.jpg', { type: 'image/jpeg' });
|
|
|
+ // 1. 压缩图片
|
|
|
+ const compressedFile = await compressImage(new File([croppedBlob], 'avatar.jpg', { type: 'image/jpeg' }), 800, 800, 0.8);
|
|
|
|
|
|
- // 上传头像
|
|
|
- await uploadAvatarMutation.mutateAsync(croppedFile);
|
|
|
+ // 2. 生成上传路径
|
|
|
+ const timestamp = Date.now();
|
|
|
+ const fileExtension = compressedFile.name.split('.').pop();
|
|
|
+ const fileKey = `avatars/${userId}/${timestamp}.${fileExtension}`;
|
|
|
|
|
|
- // 清理预览URL
|
|
|
- if (cropImageUrl) {
|
|
|
- URL.revokeObjectURL(cropImageUrl);
|
|
|
+ // 3. 上传到MinIO
|
|
|
+ const result = await uploadMinIOWithPolicy(
|
|
|
+ 'avatars',
|
|
|
+ compressedFile,
|
|
|
+ fileKey
|
|
|
+ );
|
|
|
+
|
|
|
+ // 4. 回调成功
|
|
|
+ if (onUploadSuccess) {
|
|
|
+ onUploadSuccess(result.fileId);
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
console.error('头像上传失败:', error);
|
|
|
- alert('头像上传失败,请重试');
|
|
|
+ const err = error as Error;
|
|
|
+ if (onUploadError) onUploadError(err);
|
|
|
+ else alert('头像上传失败,请重试');
|
|
|
} finally {
|
|
|
setIsUploading(false);
|
|
|
+ if (cropImageUrl) {
|
|
|
+ URL.revokeObjectURL(cropImageUrl);
|
|
|
+ }
|
|
|
setCropImageUrl(null);
|
|
|
}
|
|
|
};
|
|
|
@@ -224,11 +168,11 @@ export const AvatarUpload: React.FC<AvatarUploadProps> = ({
|
|
|
style={{ borderRadius: '50%' }}
|
|
|
/>
|
|
|
) : (
|
|
|
- <div
|
|
|
+ <div
|
|
|
className="w-full h-full flex items-center justify-center text-white font-bold"
|
|
|
style={{ fontSize: size * 0.4 }}
|
|
|
>
|
|
|
- {user?.username?.charAt(0)?.toUpperCase() || '用'}
|
|
|
+ {username?.charAt(0)?.toUpperCase() || '用'}
|
|
|
</div>
|
|
|
)}
|
|
|
|
|
|
@@ -268,7 +212,7 @@ export const AvatarUpload: React.FC<AvatarUploadProps> = ({
|
|
|
/>
|
|
|
|
|
|
{/* 上传进度提示 */}
|
|
|
- {uploadAvatarMutation.isPending && (
|
|
|
+ {isUploading && (
|
|
|
<div className="absolute -bottom-6 left-1/2 transform -translate-x-1/2">
|
|
|
<span className="text-xs" style={{ color: COLORS.text.secondary }}>
|
|
|
上传中...
|