新增頭像圖片上傳

This commit is contained in:
2025-09-21 22:11:20 +08:00
parent 38ae30d611
commit 59d22966c2
22 changed files with 1904 additions and 1437 deletions

5
.gitignore vendored
View File

@@ -24,4 +24,7 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
next-env.d.ts
# uploaded files
/public/uploads/

View File

@@ -1,76 +0,0 @@
// =====================================================
// 調試競賽數據 API
// =====================================================
import { NextRequest, NextResponse } from 'next/server';
import { DatabaseServiceBase } from '@/lib/services/database-service';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const competitionId = searchParams.get('competitionId') || '07e2303e-9647-11f0-b5d9-6e36c63cdb98';
console.log('🔍 開始調試競賽數據...');
console.log('競賽ID:', competitionId);
// 1. 檢查競賽是否存在
const competitionCheck = await DatabaseServiceBase.safeQuery(
'SELECT * FROM competitions WHERE id = ?',
[competitionId]
);
console.log('📊 競賽檢查結果:', competitionCheck);
// 2. 檢查競賽應用關聯
const competitionAppsCheck = await DatabaseServiceBase.safeQuery(
'SELECT * FROM competition_apps WHERE competition_id = ?',
[competitionId]
);
console.log('📊 競賽應用關聯檢查結果:', competitionAppsCheck);
// 3. 檢查所有應用程式
const allAppsCheck = await DatabaseServiceBase.safeQuery(
'SELECT id, name, is_active FROM apps WHERE is_active = 1 LIMIT 10',
[]
);
console.log('📊 所有應用程式檢查結果:', allAppsCheck);
// 4. 檢查競賽規則
const competitionRulesCheck = await DatabaseServiceBase.safeQuery(
'SELECT * FROM competition_rules WHERE competition_id = ?',
[competitionId]
);
console.log('📊 競賽規則檢查結果:', competitionRulesCheck);
// 5. 檢查評審
const judgesCheck = await DatabaseServiceBase.safeQuery(
'SELECT id, name, title, department FROM judges WHERE is_active = 1 LIMIT 5',
[]
);
console.log('📊 評審檢查結果:', judgesCheck);
return NextResponse.json({
success: true,
message: '調試數據獲取成功',
data: {
competition: competitionCheck,
competitionApps: competitionAppsCheck,
allApps: allAppsCheck,
competitionRules: competitionRulesCheck,
judges: judgesCheck
}
});
} catch (error) {
console.error('❌ 調試競賽數據失敗:', error);
return NextResponse.json({
success: false,
message: '調試競賽數據失敗',
error: error instanceof Error ? error.message : '未知錯誤'
}, { status: 500 });
}
}

View File

@@ -1,66 +0,0 @@
// =====================================================
// 強制清理連線 API
// =====================================================
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/database';
import { connectionMonitor } from '@/lib/connection-monitor';
export async function POST(request: NextRequest) {
try {
console.log('🧹 開始強制清理資料庫連線...');
// 獲取清理前的連線狀態
const beforeStats = await connectionMonitor.getConnectionStats();
console.log(`清理前連線數: ${beforeStats.activeConnections}`);
// 強制關閉連線池
try {
await db.close();
console.log('✅ 主要資料庫連線池已關閉');
} catch (error) {
console.error('❌ 關閉主要連線池失敗:', error);
}
// 等待一段時間讓連線完全關閉
await new Promise(resolve => setTimeout(resolve, 2000));
// 重新初始化連線池
try {
// 重新創建連線池實例
const { Database } = await import('@/lib/database');
const newDb = Database.getInstance();
console.log('✅ 資料庫連線池已重新初始化');
} catch (error) {
console.error('❌ 重新初始化連線池失敗:', error);
}
// 獲取清理後的連線狀態
const afterStats = await connectionMonitor.getConnectionStats();
console.log(`清理後連線數: ${afterStats.activeConnections}`);
return NextResponse.json({
success: true,
message: '強制清理完成',
data: {
before: {
activeConnections: beforeStats.activeConnections,
usagePercentage: beforeStats.usagePercentage
},
after: {
activeConnections: afterStats.activeConnections,
usagePercentage: afterStats.usagePercentage
},
cleaned: beforeStats.activeConnections - afterStats.activeConnections
}
});
} catch (error) {
console.error('強制清理失敗:', error);
return NextResponse.json({
success: false,
message: '強制清理失敗',
error: error instanceof Error ? error.message : '未知錯誤'
}, { status: 500 });
}
}

View File

@@ -1,70 +0,0 @@
// =====================================================
// 強制終止連線 API
// =====================================================
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/database';
export async function POST(request: NextRequest) {
try {
console.log('💀 開始強制終止所有資料庫連線...');
// 獲取所有 AI_Platform 的連線
const connections = await db.query(`
SELECT ID, USER, HOST, DB, COMMAND, TIME, STATE
FROM INFORMATION_SCHEMA.PROCESSLIST
WHERE USER = 'AI_Platform' AND DB = 'db_AI_Platform'
`);
console.log(`找到 ${connections.length} 個 AI_Platform 連線`);
const killedConnections = [];
// 終止每個連線
for (const conn of connections) {
try {
await db.query(`KILL CONNECTION ${conn.ID}`);
killedConnections.push({
id: conn.ID,
host: conn.HOST,
time: conn.TIME,
command: conn.COMMAND
});
console.log(`✅ 已終止連線 ${conn.ID} (閒置 ${conn.TIME} 秒)`);
} catch (error) {
console.error(`❌ 終止連線 ${conn.ID} 失敗:`, error);
}
}
// 等待連線完全關閉
await new Promise(resolve => setTimeout(resolve, 2000));
// 檢查剩餘連線
const remainingConnections = await db.query(`
SELECT COUNT(*) as count
FROM INFORMATION_SCHEMA.PROCESSLIST
WHERE USER = 'AI_Platform' AND DB = 'db_AI_Platform'
`);
const remainingCount = remainingConnections[0]?.count || 0;
return NextResponse.json({
success: true,
message: '強制終止連線完成',
data: {
totalFound: connections.length,
killed: killedConnections.length,
remaining: remainingCount,
killedConnections: killedConnections
}
});
} catch (error) {
console.error('強制終止連線失敗:', error);
return NextResponse.json({
success: false,
message: '強制終止連線失敗',
error: error instanceof Error ? error.message : '未知錯誤'
}, { status: 500 });
}
}

View File

@@ -1,67 +0,0 @@
// =====================================================
// 連線測試 API - 驗證連線釋放
// =====================================================
import { NextRequest, NextResponse } from 'next/server';
import { connectionMonitor } from '@/lib/connection-monitor';
export async function GET(request: NextRequest) {
try {
// 獲取測試前的連線狀態
const beforeStats = await connectionMonitor.getConnectionStats();
// 執行一些測試查詢
const testQueries = [
'SELECT 1 as test1',
'SELECT 2 as test2',
'SELECT 3 as test3',
'SELECT COUNT(*) as user_count FROM users',
'SELECT COUNT(*) as app_count FROM apps'
];
console.log('🧪 開始連線測試...');
console.log(`測試前連線數: ${beforeStats.activeConnections}`);
// 執行測試查詢
for (let i = 0; i < testQueries.length; i++) {
const { db } = await import('@/lib/database');
await db.query(testQueries[i]);
console.log(`✅ 完成測試查詢 ${i + 1}`);
}
// 等待一小段時間讓連線釋放
await new Promise(resolve => setTimeout(resolve, 1000));
// 獲取測試後的連線狀態
const afterStats = await connectionMonitor.getConnectionStats();
console.log(`測試後連線數: ${afterStats.activeConnections}`);
return NextResponse.json({
success: true,
message: '連線測試完成',
data: {
before: {
activeConnections: beforeStats.activeConnections,
usagePercentage: beforeStats.usagePercentage
},
after: {
activeConnections: afterStats.activeConnections,
usagePercentage: afterStats.usagePercentage
},
difference: {
connectionChange: afterStats.activeConnections - beforeStats.activeConnections,
isReleased: afterStats.activeConnections <= beforeStats.activeConnections
}
}
});
} catch (error) {
console.error('連線測試失敗:', error);
return NextResponse.json({
success: false,
message: '連線測試失敗',
error: error instanceof Error ? error.message : '未知錯誤'
}, { status: 500 });
}
}

View File

@@ -0,0 +1,103 @@
// =====================================================
// 個人頭像上傳 API
// =====================================================
import { NextRequest, NextResponse } from 'next/server';
import { writeFile, mkdir } from 'fs/promises';
import { join } from 'path';
import { UserService } from '@/lib/services/database-service';
import { generateUniqueFileName, isValidImageType, isValidImageSize } from '@/lib/image-utils';
import sharp from 'sharp';
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const file = formData.get('avatar') as File;
const userId = formData.get('userId') as string;
if (!file) {
return NextResponse.json(
{ success: false, error: '請選擇要上傳的圖片' },
{ status: 400 }
);
}
if (!userId) {
return NextResponse.json(
{ success: false, error: '用戶ID不能為空' },
{ status: 400 }
);
}
// 驗證文件類型
if (!isValidImageType(file)) {
return NextResponse.json(
{ success: false, error: '只支援 JPEG、PNG、WebP 格式的圖片' },
{ status: 400 }
);
}
// 驗證文件大小(限制 5MB
if (!isValidImageSize(file, 5)) {
return NextResponse.json(
{ success: false, error: '圖片大小不能超過 5MB' },
{ status: 400 }
);
}
// 創建上傳目錄
const uploadDir = join(process.cwd(), 'public', 'uploads', 'avatars');
await mkdir(uploadDir, { recursive: true });
// 生成唯一文件名
const fileName = generateUniqueFileName(file.name, userId);
const filePath = join(uploadDir, fileName);
// 讀取文件緩衝區
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// 使用 Sharp 優化圖片
const optimizedBuffer = await sharp(buffer)
.resize(200, 200, {
fit: 'cover',
position: 'center'
})
.jpeg({ quality: 85 })
.toBuffer();
// 保存優化後的圖片
await writeFile(filePath, optimizedBuffer);
// 生成相對路徑(自動適應不同環境)
const imageUrl = `/uploads/avatars/${fileName}`;
// 更新用戶頭像
const userService = new UserService();
await userService.update(userId, { avatar: imageUrl });
console.log(`✅ 用戶 ${userId} 頭像上傳成功: ${imageUrl}`);
return NextResponse.json({
success: true,
message: '頭像上傳成功',
data: {
imageUrl,
fileName,
fileSize: optimizedBuffer.length,
originalSize: file.size
}
});
} catch (error) {
console.error('頭像上傳失敗:', error);
return NextResponse.json(
{
success: false,
error: '頭像上傳失敗',
details: error instanceof Error ? error.message : '未知錯誤'
},
{ status: 500 }
);
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>
JPEGPNGWebP 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>
);
}

View File

@@ -45,6 +45,7 @@ JWT_EXPIRES_IN=7d
NEXT_PUBLIC_APP_NAME=強茂集團 AI 展示平台
NEXT_PUBLIC_APP_DESCRIPTION=企業內部 AI 應用展示與競賽管理系統
NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXT_PUBLIC_BASE_URL=http://localhost:3000
# 文件上傳配置
MAX_FILE_SIZE=10485760

63
lib/image-utils.ts Normal file
View File

@@ -0,0 +1,63 @@
// =====================================================
// 圖片處理工具
// =====================================================
/**
* 獲取完整的圖片 URL
* 自動處理本地開發和正式環境的 URL 差異
*/
export function getImageUrl(relativePath: string): string {
// 如果是絕對 URL包含 http 或 https直接返回
if (relativePath.startsWith('http://') || relativePath.startsWith('https://')) {
return relativePath;
}
// 如果是相對路徑,根據環境自動添加域名
if (relativePath.startsWith('/')) {
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ||
(typeof window !== 'undefined' ? window.location.origin : '');
return `${baseUrl}${relativePath}`;
}
// 如果沒有前綴,添加 /uploads/ 前綴
return `/uploads/${relativePath}`;
}
/**
* 獲取頭像 URL
* 如果沒有頭像,返回默認頭像
*/
export function getAvatarUrl(avatar?: string | null, userName?: string): string {
if (avatar) {
return getImageUrl(avatar);
}
// 返回默認頭像或根據用戶名生成
return `/placeholder-user.jpg`;
}
/**
* 驗證圖片文件類型
*/
export function isValidImageType(file: File): boolean {
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
return allowedTypes.includes(file.type);
}
/**
* 驗證圖片文件大小
*/
export function isValidImageSize(file: File, maxSizeMB: number = 5): boolean {
const maxSize = maxSizeMB * 1024 * 1024;
return file.size <= maxSize;
}
/**
* 生成唯一的文件名
*/
export function generateUniqueFileName(originalName: string, userId: string): string {
const timestamp = Date.now();
const randomString = Math.random().toString(36).substring(2, 15);
const fileExtension = originalName.split('.').pop() || 'jpg';
return `avatar_${userId}_${timestamp}_${randomString}.${fileExtension}`;
}

View File

@@ -81,6 +81,7 @@
"react-hook-form": "^7.54.1",
"react-resizable-panels": "^2.1.7",
"recharts": "latest",
"sharp": "^0.34.4",
"sonner": "^1.7.1",
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",

2559
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
onlyBuiltDependencies:
- sharp

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 568 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="215" height="48" fill="none"><path fill="#000" d="M57.588 9.6h6L73.828 38h-5.2l-2.36-6.88h-11.36L52.548 38h-5.2l10.24-28.4Zm7.16 17.16-4.16-12.16-4.16 12.16h8.32Zm23.694-2.24c-.186-1.307-.706-2.32-1.56-3.04-.853-.72-1.866-1.08-3.04-1.08-1.68 0-2.986.613-3.92 1.84-.906 1.227-1.36 2.947-1.36 5.16s.454 3.933 1.36 5.16c.934 1.227 2.24 1.84 3.92 1.84 1.254 0 2.307-.373 3.16-1.12.854-.773 1.387-1.867 1.6-3.28l5.12.24c-.186 1.68-.733 3.147-1.64 4.4-.906 1.227-2.08 2.173-3.52 2.84-1.413.667-2.986 1-4.72 1-2.08 0-3.906-.453-5.48-1.36-1.546-.907-2.76-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84 0-2.24.427-4.187 1.28-5.84.88-1.68 2.094-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.68 0 3.227.32 4.64.96 1.414.64 2.56 1.56 3.44 2.76.907 1.2 1.454 2.6 1.64 4.2l-5.12.28Zm11.486-7.72.12 3.4c.534-1.227 1.307-2.173 2.32-2.84 1.04-.693 2.267-1.04 3.68-1.04 1.494 0 2.76.387 3.8 1.16 1.067.747 1.827 1.813 2.28 3.2.507-1.44 1.294-2.52 2.36-3.24 1.094-.747 2.414-1.12 3.96-1.12 1.414 0 2.64.307 3.68.92s1.84 1.52 2.4 2.72c.56 1.2.84 2.667.84 4.4V38h-4.96V25.92c0-1.813-.293-3.187-.88-4.12-.56-.96-1.413-1.44-2.56-1.44-.906 0-1.68.213-2.32.64-.64.427-1.133 1.053-1.48 1.88-.32.827-.48 1.84-.48 3.04V38h-4.56V25.92c0-1.2-.133-2.213-.4-3.04-.24-.827-.626-1.453-1.16-1.88-.506-.427-1.133-.64-1.88-.64-.906 0-1.68.227-2.32.68-.64.427-1.133 1.053-1.48 1.88-.32.827-.48 1.827-.48 3V38h-4.96V16.8h4.48Zm26.723 10.6c0-2.24.427-4.187 1.28-5.84.854-1.68 2.067-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.84 0 3.494.413 4.96 1.24 1.467.827 2.64 2.08 3.52 3.76.88 1.653 1.347 3.693 1.4 6.12v1.32h-15.08c.107 1.813.614 3.227 1.52 4.24.907.987 2.134 1.48 3.68 1.48.987 0 1.88-.253 2.68-.76a4.803 4.803 0 0 0 1.84-2.2l5.08.36c-.64 2.027-1.84 3.64-3.6 4.84-1.733 1.173-3.733 1.76-6 1.76-2.08 0-3.906-.453-5.48-1.36-1.573-.907-2.786-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84Zm15.16-2.04c-.213-1.733-.76-3.013-1.64-3.84-.853-.827-1.893-1.24-3.12-1.24-1.44 0-2.6.453-3.48 1.36-.88.88-1.44 2.12-1.68 3.72h9.92ZM163.139 9.6V38h-5.04V9.6h5.04Zm8.322 7.2.24 5.88-.64-.36c.32-2.053 1.094-3.56 2.32-4.52 1.254-.987 2.787-1.48 4.6-1.48 2.32 0 4.107.733 5.36 2.2 1.254 1.44 1.88 3.387 1.88 5.84V38h-4.96V25.92c0-1.253-.12-2.28-.36-3.08-.24-.8-.64-1.413-1.2-1.84-.533-.427-1.253-.64-2.16-.64-1.44 0-2.573.48-3.4 1.44-.8.933-1.2 2.307-1.2 4.12V38h-4.96V16.8h4.48Zm30.003 7.72c-.186-1.307-.706-2.32-1.56-3.04-.853-.72-1.866-1.08-3.04-1.08-1.68 0-2.986.613-3.92 1.84-.906 1.227-1.36 2.947-1.36 5.16s.454 3.933 1.36 5.16c.934 1.227 2.24 1.84 3.92 1.84 1.254 0 2.307-.373 3.16-1.12.854-.773 1.387-1.867 1.6-3.28l5.12.24c-.186 1.68-.733 3.147-1.64 4.4-.906 1.227-2.08 2.173-3.52 2.84-1.413.667-2.986 1-4.72 1-2.08 0-3.906-.453-5.48-1.36-1.546-.907-2.76-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84 0-2.24.427-4.187 1.28-5.84.88-1.68 2.094-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.68 0 3.227.32 4.64.96 1.414.64 2.56 1.56 3.44 2.76.907 1.2 1.454 2.6 1.64 4.2l-5.12.28Zm11.443 8.16V38h-5.6v-5.32h5.6Z"/><path fill="#171717" fill-rule="evenodd" d="m7.839 40.783 16.03-28.054L20 6 0 40.783h7.839Zm8.214 0H40L27.99 19.894l-4.02 7.032 3.976 6.914H20.02l-3.967 6.943Z" clip-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 3.2 KiB