新增頭像圖片上傳
This commit is contained in:
@@ -25,7 +25,7 @@ import {
|
||||
} from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
@@ -312,6 +312,7 @@ export function AdminLayout({ children, currentPage, onPageChange }: AdminLayout
|
||||
<div className="p-4 border-t">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Avatar className="w-8 h-8">
|
||||
<AvatarImage src={user.avatar} />
|
||||
<AvatarFallback className="bg-gradient-to-r from-blue-600 to-purple-600 text-white text-sm">
|
||||
{user.name.charAt(0)}
|
||||
</AvatarFallback>
|
||||
|
@@ -1355,6 +1355,7 @@ export function UserManagement() {
|
||||
<TabsContent value="info" className="space-y-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Avatar className="w-16 h-16">
|
||||
<AvatarImage src={selectedUser.avatar} />
|
||||
<AvatarFallback className="bg-gradient-to-r from-blue-600 to-purple-600 text-white text-xl">
|
||||
{selectedUser.name ? selectedUser.name.charAt(0) : selectedUser.email.charAt(0).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
|
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useState, useEffect } from "react"
|
||||
import { useAuth } from "@/contexts/auth-context"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -31,6 +31,22 @@ export function ProfileDialog({ open, onOpenChange }: ProfileDialogProps) {
|
||||
phone: user?.phone || "",
|
||||
location: user?.location || "",
|
||||
})
|
||||
const [avatar, setAvatar] = useState(user?.avatar || "")
|
||||
|
||||
// 監聽用戶狀態變化,同步更新本地狀態
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setProfileData({
|
||||
name: user.name || "",
|
||||
email: user.email || "",
|
||||
department: user.department || "",
|
||||
bio: user.bio || "",
|
||||
phone: user.phone || "",
|
||||
location: user.location || "",
|
||||
})
|
||||
setAvatar(user.avatar || "")
|
||||
}
|
||||
}, [user])
|
||||
|
||||
const departments = ["HQBU", "ITBU", "MBU1", "MBU2", "SBU", "財務部", "人資部", "法務部"]
|
||||
|
||||
@@ -40,7 +56,7 @@ export function ProfileDialog({ open, onOpenChange }: ProfileDialogProps) {
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
await updateProfile(profileData)
|
||||
await updateProfile({ ...profileData, avatar })
|
||||
setSuccess("個人資料更新成功!")
|
||||
setTimeout(() => setSuccess(""), 3000)
|
||||
} catch (err) {
|
||||
@@ -50,6 +66,59 @@ export function ProfileDialog({ open, onOpenChange }: ProfileDialogProps) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const handleAvatarFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// 驗證文件類型
|
||||
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
setError('只支援 JPEG、PNG、WebP 格式的圖片');
|
||||
return;
|
||||
}
|
||||
|
||||
// 驗證文件大小(限制 5MB)
|
||||
const maxSize = 5 * 1024 * 1024; // 5MB
|
||||
if (file.size > maxSize) {
|
||||
setError('圖片大小不能超過 5MB');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', file);
|
||||
formData.append('userId', user?.id || '');
|
||||
|
||||
const response = await fetch('/api/upload/avatar', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setSuccess('頭像上傳成功!');
|
||||
// 更新全局用戶狀態
|
||||
await updateProfile({ avatar: result.data.imageUrl });
|
||||
setTimeout(() => setSuccess(''), 3000);
|
||||
} else {
|
||||
setError(result.error || '上傳失敗');
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : '上傳失敗';
|
||||
setError(`頭像上傳失敗:${errorMsg}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
// 清空文件輸入
|
||||
event.target.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
@@ -77,9 +146,9 @@ export function ProfileDialog({ open, onOpenChange }: ProfileDialogProps) {
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-6">
|
||||
<div className="relative">
|
||||
<div className="relative inline-block">
|
||||
<Avatar className="w-24 h-24">
|
||||
<AvatarImage src={user?.avatar} />
|
||||
<AvatarImage src={user?.avatar || avatar} />
|
||||
<AvatarFallback className="text-2xl bg-gradient-to-r from-blue-600 to-purple-600 text-white">
|
||||
{user?.name?.charAt(0) || "U"}
|
||||
</AvatarFallback>
|
||||
@@ -87,10 +156,18 @@ export function ProfileDialog({ open, onOpenChange }: ProfileDialogProps) {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="absolute -bottom-2 -right-2 rounded-full w-8 h-8 p-0 bg-transparent"
|
||||
className="absolute -bottom-1 -right-1 rounded-full w-8 h-8 p-0 bg-white/80 hover:bg-white/90 border-2 border-white/50 shadow-md backdrop-blur-sm"
|
||||
onClick={() => document.getElementById('avatar-upload')?.click()}
|
||||
>
|
||||
<Camera className="w-4 h-4" />
|
||||
</Button>
|
||||
<input
|
||||
id="avatar-upload"
|
||||
type="file"
|
||||
accept="image/jpeg,image/jpg,image/png,image/webp"
|
||||
className="hidden"
|
||||
onChange={handleAvatarFileSelect}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold">{user?.name}</h3>
|
||||
|
@@ -10,7 +10,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { User, BarChart3, Settings, LogOut, Code, Shield, Upload } from "lucide-react"
|
||||
import { LoginDialog } from "./login-dialog"
|
||||
import { RegisterDialog } from "./register-dialog"
|
||||
@@ -119,6 +119,7 @@ export function UserMenu() {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="relative h-10 w-10 rounded-full">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage src={user.avatar} />
|
||||
<AvatarFallback className="bg-gradient-to-r from-blue-600 to-purple-600 text-white">
|
||||
{user.name.charAt(0)}
|
||||
</AvatarFallback>
|
||||
@@ -128,6 +129,7 @@ export function UserMenu() {
|
||||
<DropdownMenuContent className="w-80" align="end" forceMount>
|
||||
<div className="flex items-center justify-start gap-2 p-4">
|
||||
<Avatar className="h-12 w-12">
|
||||
<AvatarImage src={user.avatar} />
|
||||
<AvatarFallback className="bg-gradient-to-r from-blue-600 to-purple-600 text-white text-lg">
|
||||
{user.name.charAt(0)}
|
||||
</AvatarFallback>
|
||||
|
231
components/avatar-upload.tsx
Normal file
231
components/avatar-upload.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
// =====================================================
|
||||
// 頭像上傳組件
|
||||
// =====================================================
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Upload, X, Check, AlertCircle } from 'lucide-react';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { isValidImageType, isValidImageSize } from '@/lib/image-utils';
|
||||
|
||||
interface AvatarUploadProps {
|
||||
userId: string;
|
||||
currentAvatar?: string;
|
||||
userName?: string;
|
||||
onUploadSuccess?: (imageUrl: string) => void;
|
||||
onUploadError?: (error: string) => void;
|
||||
}
|
||||
|
||||
export function AvatarUpload({
|
||||
userId,
|
||||
currentAvatar,
|
||||
userName,
|
||||
onUploadSuccess,
|
||||
onUploadError
|
||||
}: AvatarUploadProps) {
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadStatus, setUploadStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 監聽 currentAvatar 變化,清除預覽
|
||||
useEffect(() => {
|
||||
if (currentAvatar) {
|
||||
setPreviewUrl(null);
|
||||
}
|
||||
}, [currentAvatar]);
|
||||
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// 驗證文件類型
|
||||
if (!isValidImageType(file)) {
|
||||
setErrorMessage('只支援 JPEG、PNG、WebP 格式的圖片');
|
||||
setUploadStatus('error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 驗證文件大小(限制 5MB)
|
||||
if (!isValidImageSize(file, 5)) {
|
||||
setErrorMessage('圖片大小不能超過 5MB');
|
||||
setUploadStatus('error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 創建預覽 URL
|
||||
const url = URL.createObjectURL(file);
|
||||
setPreviewUrl(url);
|
||||
setUploadStatus('idle');
|
||||
setErrorMessage('');
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
const file = fileInputRef.current?.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setIsUploading(true);
|
||||
setUploadStatus('idle');
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', file);
|
||||
formData.append('userId', userId);
|
||||
|
||||
const response = await fetch('/api/upload/avatar', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
setUploadStatus('success');
|
||||
setPreviewUrl(null); // 清除預覽,讓組件顯示實際頭像
|
||||
onUploadSuccess?.(result.data.imageUrl);
|
||||
console.log('頭像上傳成功:', result.data);
|
||||
} else {
|
||||
setUploadStatus('error');
|
||||
setErrorMessage(result.error || '上傳失敗');
|
||||
onUploadError?.(result.error || '上傳失敗');
|
||||
}
|
||||
} catch (error) {
|
||||
setUploadStatus('error');
|
||||
const errorMsg = error instanceof Error ? error.message : '上傳失敗';
|
||||
setErrorMessage(errorMsg);
|
||||
onUploadError?.(errorMsg);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFile = () => {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
setPreviewUrl(null);
|
||||
setUploadStatus('idle');
|
||||
setErrorMessage('');
|
||||
};
|
||||
|
||||
const getDisplayAvatar = () => {
|
||||
if (previewUrl) return previewUrl;
|
||||
if (currentAvatar && currentAvatar !== '') return currentAvatar;
|
||||
return null;
|
||||
};
|
||||
|
||||
const getInitials = () => {
|
||||
if (userName) {
|
||||
return userName.split(' ').map(n => n[0]).join('').toUpperCase();
|
||||
}
|
||||
return 'U';
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Upload className="h-5 w-5" />
|
||||
頭像上傳
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
支援 JPEG、PNG、WebP 格式,最大 5MB
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 頭像預覽 */}
|
||||
<div className="flex justify-center">
|
||||
<Avatar className="h-24 w-24">
|
||||
<AvatarImage src={getDisplayAvatar() || ''} alt="頭像" />
|
||||
<AvatarFallback className="text-lg">
|
||||
{getInitials()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
|
||||
{/* 文件選擇 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="avatar">選擇圖片</Label>
|
||||
<Input
|
||||
id="avatar"
|
||||
type="file"
|
||||
accept="image/jpeg,image/jpg,image/png,image/webp"
|
||||
onChange={handleFileSelect}
|
||||
ref={fileInputRef}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 預覽和操作按鈕 */}
|
||||
{previewUrl && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">預覽</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRemoveFile}
|
||||
disabled={isUploading}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="預覽"
|
||||
className="h-16 w-16 rounded-full object-cover border"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 上傳按鈕 */}
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={!fileInputRef.current?.files?.[0] || isUploading}
|
||||
className="w-full"
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2" />
|
||||
上傳中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
上傳頭像
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* 狀態提示 */}
|
||||
{uploadStatus === 'success' && (
|
||||
<Alert>
|
||||
<Check className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
頭像上傳成功!
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{uploadStatus === 'error' && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{errorMessage}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user