新增頭像圖片上傳
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -25,3 +25,6 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# uploaded files
|
||||||
|
/public/uploads/
|
@@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
@@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
@@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
@@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
103
app/api/upload/avatar/route.ts
Normal file
103
app/api/upload/avatar/route.ts
Normal 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@@ -25,7 +25,7 @@ import {
|
|||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
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 { Badge } from "@/components/ui/badge"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
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="p-4 border-t">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<Avatar className="w-8 h-8">
|
<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">
|
<AvatarFallback className="bg-gradient-to-r from-blue-600 to-purple-600 text-white text-sm">
|
||||||
{user.name.charAt(0)}
|
{user.name.charAt(0)}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
|
@@ -1355,6 +1355,7 @@ export function UserManagement() {
|
|||||||
<TabsContent value="info" className="space-y-4">
|
<TabsContent value="info" className="space-y-4">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<Avatar className="w-16 h-16">
|
<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">
|
<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()}
|
{selectedUser.name ? selectedUser.name.charAt(0) : selectedUser.email.charAt(0).toUpperCase()}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import { useAuth } from "@/contexts/auth-context"
|
import { useAuth } from "@/contexts/auth-context"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -31,6 +31,22 @@ export function ProfileDialog({ open, onOpenChange }: ProfileDialogProps) {
|
|||||||
phone: user?.phone || "",
|
phone: user?.phone || "",
|
||||||
location: user?.location || "",
|
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", "財務部", "人資部", "法務部"]
|
const departments = ["HQBU", "ITBU", "MBU1", "MBU2", "SBU", "財務部", "人資部", "法務部"]
|
||||||
|
|
||||||
@@ -40,7 +56,7 @@ export function ProfileDialog({ open, onOpenChange }: ProfileDialogProps) {
|
|||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateProfile(profileData)
|
await updateProfile({ ...profileData, avatar })
|
||||||
setSuccess("個人資料更新成功!")
|
setSuccess("個人資料更新成功!")
|
||||||
setTimeout(() => setSuccess(""), 3000)
|
setTimeout(() => setSuccess(""), 3000)
|
||||||
} catch (err) {
|
} 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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
<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="flex items-center space-x-6">
|
||||||
<div className="relative">
|
<div className="relative inline-block">
|
||||||
<Avatar className="w-24 h-24">
|
<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">
|
<AvatarFallback className="text-2xl bg-gradient-to-r from-blue-600 to-purple-600 text-white">
|
||||||
{user?.name?.charAt(0) || "U"}
|
{user?.name?.charAt(0) || "U"}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
@@ -87,10 +156,18 @@ export function ProfileDialog({ open, onOpenChange }: ProfileDialogProps) {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
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" />
|
<Camera className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<input
|
||||||
|
id="avatar-upload"
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/jpg,image/png,image/webp"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleAvatarFileSelect}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-semibold">{user?.name}</h3>
|
<h3 className="text-xl font-semibold">{user?.name}</h3>
|
||||||
|
@@ -10,7 +10,7 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} 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 { User, BarChart3, Settings, LogOut, Code, Shield, Upload } from "lucide-react"
|
||||||
import { LoginDialog } from "./login-dialog"
|
import { LoginDialog } from "./login-dialog"
|
||||||
import { RegisterDialog } from "./register-dialog"
|
import { RegisterDialog } from "./register-dialog"
|
||||||
@@ -119,6 +119,7 @@ export function UserMenu() {
|
|||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" className="relative h-10 w-10 rounded-full">
|
<Button variant="ghost" className="relative h-10 w-10 rounded-full">
|
||||||
<Avatar className="h-10 w-10">
|
<Avatar className="h-10 w-10">
|
||||||
|
<AvatarImage src={user.avatar} />
|
||||||
<AvatarFallback className="bg-gradient-to-r from-blue-600 to-purple-600 text-white">
|
<AvatarFallback className="bg-gradient-to-r from-blue-600 to-purple-600 text-white">
|
||||||
{user.name.charAt(0)}
|
{user.name.charAt(0)}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
@@ -128,6 +129,7 @@ export function UserMenu() {
|
|||||||
<DropdownMenuContent className="w-80" align="end" forceMount>
|
<DropdownMenuContent className="w-80" align="end" forceMount>
|
||||||
<div className="flex items-center justify-start gap-2 p-4">
|
<div className="flex items-center justify-start gap-2 p-4">
|
||||||
<Avatar className="h-12 w-12">
|
<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">
|
<AvatarFallback className="bg-gradient-to-r from-blue-600 to-purple-600 text-white text-lg">
|
||||||
{user.name.charAt(0)}
|
{user.name.charAt(0)}
|
||||||
</AvatarFallback>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
@@ -45,6 +45,7 @@ JWT_EXPIRES_IN=7d
|
|||||||
NEXT_PUBLIC_APP_NAME=強茂集團 AI 展示平台
|
NEXT_PUBLIC_APP_NAME=強茂集團 AI 展示平台
|
||||||
NEXT_PUBLIC_APP_DESCRIPTION=企業內部 AI 應用展示與競賽管理系統
|
NEXT_PUBLIC_APP_DESCRIPTION=企業內部 AI 應用展示與競賽管理系統
|
||||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||||
|
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||||
|
|
||||||
# 文件上傳配置
|
# 文件上傳配置
|
||||||
MAX_FILE_SIZE=10485760
|
MAX_FILE_SIZE=10485760
|
||||||
|
63
lib/image-utils.ts
Normal file
63
lib/image-utils.ts
Normal 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}`;
|
||||||
|
}
|
@@ -81,6 +81,7 @@
|
|||||||
"react-hook-form": "^7.54.1",
|
"react-hook-form": "^7.54.1",
|
||||||
"react-resizable-panels": "^2.1.7",
|
"react-resizable-panels": "^2.1.7",
|
||||||
"recharts": "latest",
|
"recharts": "latest",
|
||||||
|
"sharp": "^0.34.4",
|
||||||
"sonner": "^1.7.1",
|
"sonner": "^1.7.1",
|
||||||
"tailwind-merge": "^2.5.5",
|
"tailwind-merge": "^2.5.5",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
2559
pnpm-lock.yaml
generated
2559
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal 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 |
@@ -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 |
@@ -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 |
Reference in New Issue
Block a user