更新皮肤分类功能,添加帮助页面和Yggdrasil教程

This commit is contained in:
Mikuisnotavailable
2025-10-06 18:54:26 +08:00
parent 695001157a
commit 0645948fcc
46 changed files with 4180 additions and 1312 deletions

View File

@@ -1,59 +1,275 @@
// src/components/Navbar.tsx
'use client';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/api/auth';
import { signOut } from '@/lib/api/auth';
import { Session } from 'next-auth';
import { serverSignOut } from '@/lib/api/actions';
import { useState, FunctionComponent } from 'react';
import { Menu, X } from 'lucide-react';
import Image from 'next/image';
export default async function Navbar() {
const session = await getServerSession(authOptions);
// 定义组件Props接口
interface NavbarProps {
session: Session | null;
}
// 处理客户端退出函数
const handleSignOut = async () => {
'use server';
await signOut();
};
// 移动端菜单组件
interface MobileMenuProps {
session: Session | null;
userName: string;
serverSignOut: () => void;
handleProtectedLinkClick: () => void;
}
const MobileMenu: FunctionComponent<MobileMenuProps> = ({ session, userName, serverSignOut, handleProtectedLinkClick }) => {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="md:hidden">
<Button
variant="ghost"
size="icon"
onClick={() => setIsOpen(!isOpen)}
className="text-white hover:bg-gray-700"
>
{isOpen ? <X size={24} /> : <Menu size={24} />}
</Button>
{/* 移动端下拉菜单 */}
{isOpen && (
<div className="absolute top-16 right-4 bg-gray-800 border border-gray-700 rounded-lg shadow-lg py-2 w-48 z-50">
<div className="space-y-2 px-2">
{session ? (
<>
<Link
href="/user-home"
className="block px-4 py-2 hover:bg-gray-700 rounded hover:text-emerald-400 transition-colors"
onClick={() => setIsOpen(false)}
>
</Link>
<Link
href="/skins"
className="block px-4 py-2 hover:bg-gray-700 rounded hover:text-emerald-400 transition-colors"
onClick={() => setIsOpen(false)}
>
</Link>
<Link
href="/character-center"
className="block px-4 py-2 hover:bg-gray-700 rounded hover:text-emerald-400 transition-colors"
onClick={() => setIsOpen(false)}
>
</Link>
</>
) : (
<>
<button
onClick={() => {
handleProtectedLinkClick();
setIsOpen(false);
}}
className="block w-full text-left px-4 py-2 hover:bg-gray-700 rounded cursor-pointer hover:text-emerald-400 transition-colors"
style={{ textDecoration: 'underline', textDecorationStyle: 'dotted' }}
>
</button>
<button
onClick={() => {
handleProtectedLinkClick();
setIsOpen(false);
}}
className="block w-full text-left px-4 py-2 hover:bg-gray-700 rounded cursor-pointer hover:text-emerald-400 transition-colors"
style={{ textDecoration: 'underline', textDecorationStyle: 'dotted' }}
>
</button>
<button
onClick={() => {
handleProtectedLinkClick();
setIsOpen(false);
}}
className="block w-full text-left px-4 py-2 hover:bg-gray-700 rounded cursor-pointer hover:text-emerald-400 transition-colors"
style={{ textDecoration: 'underline', textDecorationStyle: 'dotted' }}
>
</button>
</>
)}
{session && (
<div className="border-t border-gray-700 my-1 pt-2">
<p className="px-4 mb-2 text-sm text-gray-300">, {userName}</p>
<form action={serverSignOut} className="w-full">
<Button
variant="outline"
type="submit"
className="w-full text-red-300 border-white hover:bg-gray-700 hover:text-red-200"
>
退
</Button>
</form>
</div>
)}
{!session && (
<div className="border-t border-gray-700 my-1 pt-2 space-y-2">
<Button asChild variant="outline" className="w-full text-emerald-400 border-emerald-400/30 hover:bg-gray-700 hover:text-emerald-300 transition-all duration-300">
<Link href="/login" onClick={() => setIsOpen(false)}></Link>
</Button>
<Button asChild className="w-full bg-emerald-600 hover:bg-emerald-700 transition-all duration-300 transform hover:-translate-y-0.5">
<Link href="/register" onClick={() => setIsOpen(false)}></Link>
</Button>
</div>
)}
</div>
</div>
)}
</div>
);
};
// 移动Navbar组件到客户端
const Navbar: FunctionComponent<NavbarProps> = ({ session }) => {
// 添加日志以便调试
console.log('Navbar会话状态:', session ? '已登录' : '未登录');
console.log('会话用户:', session?.user);
// 安全地获取用户名
const userName = session?.user?.name || '玩家';
// 控制登录提示弹窗的状态
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
// 处理需要登录的链接点击
const handleProtectedLinkClick = () => {
if (!session) {
setShowLoginPrompt(true);
}
};
return (
<nav className="bg-gray-800 text-white py-4">
<div className="container mx-auto px-4 flex justify-between items-center">
<div className="flex items-center space-x-6">
<Link href="/" className="text-xl font-bold"></Link>
<div className="hidden md:flex space-x-4">
<Link href="/dashboard" className="hover:text-gray-300"></Link>
<Link href="/skins" className="hover:text-gray-300"></Link>
<>
<nav className="bg-gray-900 text-white py-4 shadow-md backdrop-blur-sm bg-opacity-90 z-100">
<div className="container mx-auto px-4 flex justify-between items-center">
<div className="flex items-center space-x-6">
<Link href="/" className="flex items-center space-x-2 group">
<Image
src="/images/mc-favicon.ico"
alt="Logo"
width={32}
height={32}
className="rounded-xl w-8 h-8 transition-transform duration-300 group-hover:scale-110"
/>
<span className="text-xl font-bold bg-gradient-to-r from-emerald-400 to-teal-400 text-transparent bg-clip-text"></span>
</Link>
{/* 桌面端导航链接 */}
<div className="hidden md:flex space-x-4">
{session ? (
<>
<Link href="/user-home" className="hover:text-emerald-400 transition-colors"></Link>
<Link href="/skins" className="hover:text-emerald-400 transition-colors"></Link>
<Link href="/character-center" className="hover:text-emerald-400 transition-colors"></Link>
</>
) : (
<>
<button
onClick={handleProtectedLinkClick}
className="hover:text-emerald-400 cursor-pointer transition-colors"
style={{ textDecoration: 'underline', textDecorationStyle: 'dotted' }}
>
</button>
<button
onClick={handleProtectedLinkClick}
className="hover:text-emerald-400 cursor-pointer transition-colors"
style={{ textDecoration: 'underline', textDecorationStyle: 'dotted' }}
>
</button>
<button
onClick={handleProtectedLinkClick}
className="hover:text-emerald-400 cursor-pointer transition-colors"
style={{ textDecoration: 'underline', textDecorationStyle: 'dotted' }}
>
</button>
</>
)}
</div>
</div>
</div>
<div className="flex items-center space-x-4">
{session ? (
<div className="flex items-center space-x-4">
<span>, {userName}</span>
<form action={handleSignOut}>
<Button
variant="outline"
<div className="flex items-center space-x-4">
{/* 桌面端登录状态 */}
<div className="hidden md:flex items-center space-x-4">
{session && <span>, {userName}</span>}
{session ? (
<form action={serverSignOut}>
<Button
variant="outline"
type="submit"
className="text-white border-white hover:bg-gray-700"
className="text-red-300 border-white hover:bg-gray-700 hover:text-red-200 transition-all duration-300"
>
退
</Button>
</form>
</form>
) : (
<>
<Button asChild variant="outline" className="text-emerald-400 border-emerald-400/30 hover:bg-gray-700 hover:text-emerald-300 transition-all duration-300">
<Link href="/login"></Link>
</Button>
<Button asChild className="bg-emerald-600 hover:bg-emerald-700 transition-all duration-300 transform hover:-translate-y-0.5">
<Link href="/register"></Link>
</Button>
</>
)}
</div>
) : (
<div className="flex space-x-2">
<Button asChild variant="outline" className="text-white border-white hover:bg-gray-700">
{/* 移动端菜单按钮 */}
<MobileMenu session={session} userName={userName} serverSignOut={serverSignOut} handleProtectedLinkClick={handleProtectedLinkClick} />
</div>
</div>
</nav>
{/* 登录提示弹窗 */}
{showLoginPrompt && (
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 animate-fadeIn"
onClick={() => setShowLoginPrompt(false)}
>
<div
className="bg-white/95 dark:bg-gray-800/95 backdrop-blur-md rounded-xl shadow-2xl p-8 max-w-md w-full mx-4 transform transition-all duration-300 animate-slideUp"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-4 text-center"></h3>
<p className="text-gray-700 dark:text-gray-300 mb-6 text-center">
访
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Button
asChild
className="bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl px-6 py-2.5 shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1"
onClick={() => setShowLoginPrompt(false)}
>
<Link href="/login"></Link>
</Button>
<Button asChild className="bg-green-600 hover:bg-green-700">
<Button
asChild
variant="outline"
className="border-emerald-600 text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-900/10 rounded-xl px-6 py-2.5 shadow-md hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1"
onClick={() => setShowLoginPrompt(false)}
>
<Link href="/register"></Link>
</Button>
</div>
)}
</div>
</div>
</div>
</nav>
)}
</>
);
}
};
// 直接导出客户端Navbar组件会话获取将由上层组件处理
export default Navbar;

View File

@@ -0,0 +1,259 @@
// src/components/ServerInfoBubble.tsx
'use client';
import { useState, useEffect, useRef } from 'react';
import Image from 'next/image';
interface ServerInfoBubbleProps {
zIndex?: number;
dragDistanceLimit?: number;
}
export default function ServerInfoBubble({
zIndex = 1000,
dragDistanceLimit = 200
}: ServerInfoBubbleProps) {
// 服务器信息,这些将轮流显示
const serverInfo = {
players: {
title: '在线玩家',
content: '服务器内有 {playerCount} 名玩家正在游玩'
},
address: {
title: '服务器地址',
content: 'mc.hitwh.edu.cn:25565'
},
description: {
title: '服务器介绍',
content: '欢迎来到HITWH Minecraft服务器这是一个充满创造力的社区'
}
};
const [currentInfo, setCurrentInfo] = useState<'players' | 'address' | 'description'>('players');
const [displayText, setDisplayText] = useState('');
const [cursorVisible, setCursorVisible] = useState(true);
const [isTyping, setIsTyping] = useState(true);
const [playerCount, setPlayerCount] = useState(0); // 模拟玩家数量
// 拖拽相关状态
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const initialDragPos = useRef({ x: 0, y: 0 });
const bubbleRef = useRef<HTMLDivElement>(null);
// 随机生成玩家数量(模拟从服务器获取)
useEffect(() => {
const updatePlayerCount = () => {
setPlayerCount(Math.floor(Math.random() * 50) + 1); // 1-50随机玩家数
};
updatePlayerCount();
const countInterval = setInterval(updatePlayerCount, 30000); // 每30秒更新一次
return () => clearInterval(countInterval);
}, []);
// 打字机效果
useEffect(() => {
if (!isTyping) return;
const fullText = serverInfo[currentInfo].content.replace('{playerCount}', playerCount.toString());
let index = 0;
const typeInterval = setInterval(() => {
if (index < fullText.length) {
setDisplayText(fullText.slice(0, index + 1));
index++;
} else {
clearInterval(typeInterval);
setIsTyping(false);
}
}, 50); // 打字速度
return () => clearInterval(typeInterval);
}, [currentInfo, isTyping, playerCount]);
// 光标闪烁效果
useEffect(() => {
const cursorInterval = setInterval(() => {
setCursorVisible(prev => !prev);
}, 530);
return () => clearInterval(cursorInterval);
}, []);
// 轮流显示不同信息
useEffect(() => {
const infoInterval = setInterval(() => {
setIsTyping(true);
setDisplayText('');
// 切换到下一个信息
if (currentInfo === 'players') {
setCurrentInfo('address');
} else if (currentInfo === 'address') {
setCurrentInfo('description');
} else {
setCurrentInfo('players');
}
}, 8000); // 每个信息显示8秒
return () => clearInterval(infoInterval);
}, [currentInfo]);
// 动态计算右下角位置
const [screenPosition, setScreenPosition] = useState({ bottom: 16, right: 16 });
// 监听窗口大小变化,重新计算位置
useEffect(() => {
const updatePosition = () => {
setScreenPosition({ bottom: 16, right: 16 });
};
// 初始计算位置
updatePosition();
// 监听窗口大小变化
window.addEventListener('resize', updatePosition);
return () => {
window.removeEventListener('resize', updatePosition);
};
}, []);
// 开始拖拽
const startDragging = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
initialDragPos.current = {
x: e.clientX - dragPosition.x,
y: e.clientY - dragPosition.y
};
// 暂停动画并改变光标
if (bubbleRef.current) {
bubbleRef.current.style.animationPlayState = 'paused';
bubbleRef.current.style.cursor = 'grabbing';
}
};
// 结束拖拽并返回原位
const endDragging = () => {
if (!isDragging) return;
setIsDragging(false);
// 恢复动画和光标
if (bubbleRef.current) {
bubbleRef.current.style.animationPlayState = 'running';
bubbleRef.current.style.cursor = 'move';
}
// 使用CSS动画返回原位
if (bubbleRef.current) {
bubbleRef.current.style.transition = 'transform 0.8s cubic-bezier(0.34, 1.56, 0.64, 1)';
bubbleRef.current.style.transform = 'translate(0, 0)';
// 更新状态并清除过渡效果
setTimeout(() => {
setDragPosition({ x: 0, y: 0 });
if (bubbleRef.current) {
bubbleRef.current.style.transition = '';
}
}, 800);
}
};
// 处理拖拽移动
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging) return;
const newX = e.clientX - initialDragPos.current.x;
const newY = e.clientY - initialDragPos.current.y;
// 限制拖拽范围
const limitedX = Math.max(-dragDistanceLimit, Math.min(dragDistanceLimit, newX));
const limitedY = Math.max(-dragDistanceLimit, Math.min(dragDistanceLimit, newY));
// 直接操作DOM进行拖动避免频繁的状态更新
if (bubbleRef.current) {
bubbleRef.current.style.transform = `translate(${limitedX}px, ${limitedY}px)`;
}
// 同时更新状态以便组件状态一致
setDragPosition({ x: limitedX, y: limitedY });
};
// 设置全局鼠标事件监听
useEffect(() => {
if (isDragging) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', endDragging);
document.addEventListener('mouseleave', endDragging);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', endDragging);
document.removeEventListener('mouseleave', endDragging);
};
}
}, [isDragging, dragDistanceLimit]);
return (
<div
ref={bubbleRef}
className="fixed flex flex-col sm:flex-row gap-4 animate-float cursor-move"
style={{
zIndex,
bottom: `${screenPosition.bottom}px`,
right: `${screenPosition.right}px`,
userSelect: 'none',
touchAction: 'none',
pointerEvents: 'all',
transform: `translate(${dragPosition.x}px, ${dragPosition.y}px)`
}}
onMouseDown={startDragging}
>
{/* 服务器头像 */}
<div className="relative">
<div className="absolute -inset-1 bg-gradient-to-r from-emerald-400 to-teal-500 rounded-full blur-md opacity-75"></div>
<div className="relative w-16 h-16 rounded-full bg-gray-900 overflow-hidden border-2 border-white dark:border-gray-800 shadow-lg">
<div className="w-full h-full flex items-center justify-center bg-emerald-600 text-white font-bold text-xl">
</div>
</div>
</div>
{/* 对话框气泡 */}
<div className="relative bg-white dark:bg-gray-800 rounded-2xl p-4 max-w-xs shadow-xl border border-emerald-200 dark:border-emerald-900/30">
{/* 气泡尾部 */}
<div className="absolute top-1/2 transform -translate-y-1/2 w-4 h-4 bg-white dark:bg-gray-800 border-t border-l border-emerald-200 dark:border-emerald-900/30 left-0 -translate-x-2 rotate-45"></div>
{/* 气泡内容 */}
<div className="font-medium text-emerald-600 dark:text-emerald-400 mb-2">
{serverInfo[currentInfo].title}
</div>
<div className="text-sm text-gray-700 dark:text-gray-300 relative min-h-[2rem]">
{displayText}
<span className={`inline-block w-2 h-4 ml-0.5 bg-gray-500 dark:bg-gray-400 transition-opacity ${cursorVisible && isTyping ? 'opacity-100' : 'opacity-0'}`}></span>
</div>
</div>
</div>
);
}
// 添加浮动动画样式
export const ServerInfoBubbleStyles = `
@keyframes float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-8px);
}
}
.animate-float {
animation: float 6s ease-in-out infinite;
}`;

View File

@@ -1,9 +1,11 @@
// src/components/auth/AuthForm.tsx
// 认证表单组件 - 包含登录和注册功能,同时提供测试账号信息
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { signIn } from 'next-auth/react';
interface AuthFormProps {
type: 'login' | 'register';
@@ -20,12 +22,30 @@ export default function AuthForm({ type }: AuthFormProps) {
e.preventDefault();
setIsLoading(true);
// 这里应该调用认证API
console.log('提交表单', { username, password, email, minecraftUsername });
// 模拟API请求
await new Promise(resolve => setTimeout(resolve, 1000));
setIsLoading(false);
try {
if (type === 'login') {
// 使用next-auth的signIn功能进行登录
const result = await signIn('credentials', {
username: username || email, // 使用username或email作为用户名字段
email: email, // 传递email字段
password,
redirect: true, // 登录成功后自动重定向
callbackUrl: '/user-home' // 指定重定向目标
});
console.log('登录结果:', result);
} else {
// 注册逻辑可以在这里实现
console.log('注册信息', { username, password, email, minecraftUsername });
// 模拟注册请求
await new Promise(resolve => setTimeout(resolve, 1000));
}
} catch (error) {
console.error('认证失败:', error);
// 这里可以添加错误处理逻辑
} finally {
setIsLoading(false);
}
};
return (
@@ -35,11 +55,24 @@ export default function AuthForm({ type }: AuthFormProps) {
<Input
id="username"
type="text"
required
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={type === 'login' ? "用户名或邮箱" : "输入用户名"}
/>
</div>
{type === 'login' && (
<div>
<Label htmlFor="email"></Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="或直接输入邮箱登录"
/>
</div>
)}
<div>
<Label htmlFor="password"></Label>
<Input
@@ -79,6 +112,14 @@ export default function AuthForm({ type }: AuthFormProps) {
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? '处理中...' : type === 'login' ? '登录' : '注册'}
</Button>
{type === 'login' && (
<div className="mt-4 p-3 bg-emerald-50 dark:bg-emerald-900/20 rounded-lg text-sm border border-emerald-200 dark:border-emerald-800">
<div className="font-medium mb-1 text-emerald-700 dark:text-emerald-400"></div>
<div>: <span className="font-mono bg-white dark:bg-gray-800 px-1.5 py-0.5 rounded">test</span></div>
<div>: <span className="font-mono bg-white dark:bg-gray-800 px-1.5 py-0.5 rounded">test</span></div>
</div>
)}
</form>
);
}

View File

@@ -0,0 +1,81 @@
// Canvas 2D皮肤预览组件 - 只渲染头部正脸
'use client';
import React, { useRef, useEffect } from 'react';
interface Canvas2DSkinPreviewProps {
skinUrl: string;
size?: number;
className?: string;
}
const Canvas2DSkinPreview: React.FC<Canvas2DSkinPreviewProps> = ({
skinUrl,
size = 128,
className = ''
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// 设置canvas尺寸
canvas.width = size;
canvas.height = size;
// 清空canvas
ctx.clearRect(0, 0, size, size);
// 创建图像对象加载皮肤
const skinImage = new Image();
skinImage.src = skinUrl;
skinImage.crossOrigin = 'anonymous'; // 确保可以跨域访问
skinImage.onload = () => {
// 确保使用像素化渲染
ctx.imageSmoothingEnabled = false;
// 皮肤头部正脸坐标64x64皮肤
const sourceX = 8;
const sourceY = 8;
const sourceSize = 8;
// 计算缩放比例
const scale = size / sourceSize;
// 绘制皮肤头部正脸(像素化渲染)
ctx.drawImage(
skinImage,
sourceX, // 源图像的x坐标
sourceY, // 源图像的y坐标
sourceSize, // 源图像的宽度
sourceSize, // 源图像的高度
0, // 目标canvas的x坐标
0, // 目标canvas的y坐标
size, // 目标canvas的宽度
size // 目标canvas的高度
);
};
// 错误处理
skinImage.onerror = () => {
console.error('无法加载皮肤图片:', skinUrl);
// 绘制一个默认的灰色方块
ctx.fillStyle = '#cccccc';
ctx.fillRect(0, 0, size, size);
};
}, [skinUrl, size]);
return (
<canvas
ref={canvasRef}
className={`image-rendering-pixelated ${className}`}
style={{ imageRendering: 'pixelated' }} // 确保所有浏览器都使用像素化渲染
/>
);
};
export default Canvas2DSkinPreview;

View File

@@ -1,6 +1,7 @@
// src/components/skins/SkinGrid.tsx
import Link from 'next/link';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import Canvas2DSkinPreview from './Canvas2DSkinPreview';
interface Skin {
id: string;
@@ -10,19 +11,34 @@ interface Skin {
export default function SkinGrid({ skins }: { skins: Skin[] }) {
if (skins.length === 0) {
return <p></p>;
return null; // 空状态由父组件处理
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{skins.map((skin) => (
<Link key={skin.id} href={`/skins/${skin.id}`}>
<Card className="hover:shadow-lg transition-shadow">
<CardHeader>
<CardTitle>{skin.name}</CardTitle>
<Link
key={skin.id}
href={`/skins/${skin.id}`}
className="block transform transition-all duration-300 hover:-translate-y-1"
>
<Card className="h-full border border-gray-100 dark:border-gray-700 overflow-hidden group">
<div className="absolute inset-0 bg-gradient-to-br from-emerald-50 to-teal-50 dark:from-emerald-900/5 dark:to-teal-900/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
<div className="aspect-square bg-gray-50 dark:bg-gray-900 flex items-center justify-center p-4 relative">
<div className="absolute inset-0 bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
<Canvas2DSkinPreview
skinUrl={`/test-skin.png`}
size={128}
className="max-w-full max-h-full relative z-10 transition-transform duration-500 group-hover:scale-110"
/>
</div>
<CardHeader className="relative z-10 pb-2">
<CardTitle className="text-lg font-bold text-gray-800 dark:text-white">{skin.name}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-500">: {skin.createdAt}</p>
<CardContent className="relative z-10">
<p className="text-sm text-gray-500 dark:text-gray-400">: {skin.createdAt}</p>
</CardContent>
</Card>
</Link>

View File

@@ -1,18 +0,0 @@
// src/components/skins/SkinUploader.tsx
import Image from 'next/image';
export default function SkinUploader({ file }: { file: File }) {
const imageUrl = URL.createObjectURL(file);
return (
<div className="border rounded-md p-2 bg-gray-50">
<Image
src={imageUrl}
alt="皮肤预览"
width={128}
height={64}
className="object-contain mx-auto"
/>
</div>
);
}

View File

@@ -1,81 +0,0 @@
// src/components/skins/SkinViewer3D.tsx
//这部分还没写完!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
'use client';
import { extend, Canvas } from '@react-three/fiber';
import { OrbitControls, useTexture } from '@react-three/drei';
import { Suspense } from 'react';
import * as THREE from 'three';
// 显式扩展 Three.js 类
extend({
Mesh: THREE.Mesh,
BoxGeometry: THREE.BoxGeometry,
MeshStandardMaterial: THREE.MeshStandardMaterial,
AmbientLight: THREE.AmbientLight,
SpotLight: THREE.SpotLight,
MeshBasicMaterial: THREE.MeshBasicMaterial
});
interface SkinModelProps {
skinUrl: string;
}
const SkinModel: React.FC<SkinModelProps> = ({ skinUrl }) => {
const texture = useTexture(skinUrl);
return (
<mesh rotation={[0, Math.PI / 4, 0]}>
<boxGeometry args={[1, 2, 0.5]} />
<meshStandardMaterial map={texture} side={THREE.DoubleSide} />
</mesh>
);
};
const FallbackModel: React.FC = () => (
<mesh>
<boxGeometry args={[1, 1, 1]} />
<meshBasicMaterial color="#cccccc" wireframe opacity={0.5} transparent />
</mesh>
);
interface SkinViewer3DProps {
skinUrl: string;
}
const SkinViewer3D: React.FC<SkinViewer3DProps> = ({ skinUrl }) => {
return (
<div className="w-full h-96 bg-gray-100 dark:bg-gray-800 rounded-lg">
<Canvas
camera={{
position: [3, 1.5, 3],
fov: 50,
near: 0.1,
far: 100
}}
shadows
>
<ambientLight intensity={0.7} />
<spotLight
position={[5, 8, 5]}
angle={0.3}
penumbra={1}
intensity={1.5}
castShadow
shadow-mapSize-width={1024}
shadow-mapSize-height={1024}
/>
<Suspense fallback={<FallbackModel />}>
<SkinModel skinUrl={skinUrl} />
<OrbitControls
enableZoom={true}
enablePan={true}
minPolarAngle={Math.PI / 6}
maxPolarAngle={Math.PI / 1.8}
/>
</Suspense>
</Canvas>
</div>
);
};
export default SkinViewer3D;

View File

@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border border-gray-200 dark:border-gray-700 py-6 shadow-lg relative transition-all duration-300 hover:-translate-y-1 hover:shadow-xl",
className
)}
{...props}