Просмотр исходного кода

✨ feat(avatar): add avatar uploader component

- create AvatarUploader component with image preview functionality
- implement file type and size validation (max 2MB)
- add upload status indicators and success animation
- update MemberPage to use new AvatarUploader component
- add hover controls for triggering file selection
- include image size recommendation (200x200px)
- support initial avatar display from user data
yourname 8 месяцев назад
Родитель
Сommit
44797eb260

+ 134 - 0
src/client/home/components/AvatarUploader.tsx

@@ -0,0 +1,134 @@
+import React, { useState, useRef, ChangeEvent } from 'react';
+import { CameraIcon, PlusIcon, CheckIcon } from '@heroicons/react/24/outline';
+
+interface AvatarUploaderProps {
+  initialAvatar?: string;
+  onUpload?: (file: File) => void;
+}
+
+export const AvatarUploader: React.FC<AvatarUploaderProps> = ({ initialAvatar }) => {
+  const [isUploading, setIsUploading] = useState<boolean>(false);
+  const [preview, setPreview] = useState<string | null>(initialAvatar || null);
+  const [showControls, setShowControls] = useState<boolean>(false);
+  const [uploadSuccess, setUploadSuccess] = useState<boolean>(false);
+  const fileInputRef = useRef<HTMLInputElement>(null);
+
+  // 处理文件选择
+  const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
+    const file = e.target.files?.[0];
+    if (!file) return;
+
+    // 检查文件类型
+    if (!file.type.startsWith('image/')) {
+      alert('请选择图片文件');
+      return;
+    }
+
+    // 检查文件大小 (限制2MB)
+    if (file.size > 2 * 1024 * 1024) {
+      alert('图片大小不能超过2MB');
+      return;
+    }
+
+    // 显示预览
+    const reader = new FileReader();
+    reader.onload = (event) => {
+      setPreview(event.target?.result as string);
+      // 模拟上传过程
+      simulateUpload(file);
+    };
+    reader.readAsDataURL(file);
+
+    // 重置input值,以便可以重复选择同一文件
+    if (e.target) e.target.value = '';
+  };
+
+  // 模拟上传过程
+  const simulateUpload = (file: File) => {
+    setIsUploading(true);
+    setUploadSuccess(false);
+    
+    // 模拟1.5秒上传时间
+    setTimeout(() => {
+      setIsUploading(false);
+      setUploadSuccess(true);
+      
+      // 3秒后隐藏成功提示
+      setTimeout(() => {
+        setUploadSuccess(false);
+      }, 3000);
+    }, 1500);
+  };
+
+  // 触发文件选择对话框
+  const triggerFileSelect = () => {
+    fileInputRef.current?.click();
+  };
+
+  return (
+    <div 
+      className="relative group"
+      onMouseEnter={() => setShowControls(true)}
+      onMouseLeave={() => setShowControls(false)}
+    >
+      {/* 头像预览区域 */}
+      <div className="w-24 h-24 rounded-full bg-gray-200 flex items-center justify-center overflow-hidden border-2 border-white shadow-sm">
+        {preview ? (
+          <img 
+            src={preview} 
+            alt="用户头像" 
+            className="h-full w-full object-cover rounded-full transition-transform duration-300 group-hover:scale-105"
+          />
+        ) : (
+          <div className="text-center">
+            <CameraIcon className="h-12 w-12 text-gray-400 mx-auto" />
+            <span className="text-xs text-gray-500 mt-1 block">上传头像</span>
+          </div>
+        )}
+        
+        {/* 上传状态指示器 */}
+        {isUploading && (
+          <div className="absolute inset-0 bg-black bg-opacity-50 rounded-full flex items-center justify-center">
+            <div className="h-8 w-8 border-4 border-white border-t-transparent rounded-full animate-spin"></div>
+          </div>
+        )}
+        
+        {/* 上传成功指示器 */}
+        {uploadSuccess && (
+          <div className="absolute inset-0 bg-green-500 bg-opacity-70 rounded-full flex items-center justify-center">
+            <CheckIcon className="h-10 w-10 text-white" />
+          </div>
+        )}
+      </div>
+      
+      {/* 上传控制按钮 - 悬停时显示 */}
+      {(showControls && !isUploading && !uploadSuccess) && (
+        <div className="absolute -bottom-2 -right-2 bg-blue-600 rounded-full p-2 shadow-md cursor-pointer hover:bg-blue-700 transition-colors">
+          <PlusIcon
+            className="h-5 w-5 text-white" 
+            onClick={triggerFileSelect}
+            title="更换头像"
+          />
+        </div>
+      )}
+      
+      {/* 隐藏的文件输入 */}
+      <input
+        type="file"
+        ref={fileInputRef}
+        accept="image/*"
+        className="hidden"
+        onChange={handleFileChange}
+      />
+      
+      {/* 裁剪提示 */}
+      {(preview && !isUploading && !uploadSuccess) && (
+        <p className="text-xs text-gray-500 mt-2 text-center">
+          点击上传新头像 (建议尺寸: 200x200px)
+        </p>
+      )}
+    </div>
+  );
+};
+
+export default AvatarUploader;

+ 2 - 7
src/client/home/pages/MemberPage.tsx

@@ -1,6 +1,7 @@
 import debug from 'debug';
 import React from 'react';
 import { UserIcon, PencilIcon } from '@heroicons/react/24/outline';
+import AvatarUploader from '@/client/home/components/AvatarUploader';
 import { useParams, useNavigate } from 'react-router-dom';
 import { useQuery } from '@tanstack/react-query';
 import type { InferResponseType } from 'hono/client';
@@ -31,13 +32,7 @@ const MemberPage: React.FC = () => {
         {/* 用户资料卡片 */}
         <div className="bg-white rounded-lg shadow-sm p-6 mb-8">
           <div className="flex flex-col items-center">
-            <div className="w-24 h-24 rounded-full bg-gray-200 flex items-center justify-center mb-4">
-              {user.avatar ? (
-                <img src={user.avatar} alt={user.nickname || user.username} className="h-full w-full object-cover rounded-full" />
-              ) : (
-                <UserIcon className="h-12 w-12 text-gray-500" />
-              )}
-            </div>
+            <AvatarUploader initialAvatar={user.avatar} />
             
             <h1 className="text-2xl font-bold text-gray-900 mb-1">{user.nickname || user.username}</h1>
             

+ 1 - 0
src/client/home/routes.tsx

@@ -1,4 +1,5 @@
 import React from 'react';
+import { UserIcon } from '@heroicons/react/24/outline';
 import { createBrowserRouter, Navigate } from 'react-router-dom';
 import { ProtectedRoute } from './components/ProtectedRoute';
 import { ErrorPage } from './components/ErrorPage';