forked from CarrotSkin/carrotskin
Initial commit: CarrotSkin project setup
This commit is contained in:
@@ -8,6 +8,11 @@
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
--navbar-height: 64px; /* 与pt-16对应 */
|
||||
--primary-orange: #f97316;
|
||||
--primary-orange-dark: #ea580c;
|
||||
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-normal: 300ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-slow: 500ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@@ -21,6 +26,7 @@ body {
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
font-family: 'Inter', Arial, Helvetica, sans-serif;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Custom utility classes */
|
||||
@@ -28,34 +34,65 @@ body {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* Custom component classes */
|
||||
/* Enhanced Custom component classes with micro-interactions */
|
||||
.btn-carrot {
|
||||
background-color: #f97316;
|
||||
background-color: var(--primary-orange);
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
transition: background-color 0.2s;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
transition: all var(--transition-normal);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn-carrot:hover {
|
||||
background-color: #ea580c;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||
background-color: var(--primary-orange-dark);
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-carrot:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.btn-carrot::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||
transition: left var(--transition-slow);
|
||||
}
|
||||
|
||||
.btn-carrot:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.btn-carrot-outline {
|
||||
border: 2px solid #f97316;
|
||||
color: #f97316;
|
||||
border: 2px solid var(--primary-orange);
|
||||
color: var(--primary-orange);
|
||||
font-weight: 500;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s;
|
||||
transition: all var(--transition-normal);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn-carrot-outline:hover {
|
||||
background-color: #f97316;
|
||||
background-color: var(--primary-orange);
|
||||
color: white;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 15px -3px rgba(249, 115, 22, 0.3);
|
||||
}
|
||||
|
||||
.btn-carrot-outline:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.card-minecraft {
|
||||
@@ -63,11 +100,31 @@ body {
|
||||
border: 2px solid #fed7aa;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s;
|
||||
transition: all var(--transition-normal);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-minecraft:hover {
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-4px);
|
||||
border-color: var(--primary-orange);
|
||||
}
|
||||
|
||||
.card-minecraft::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(135deg, rgba(249, 115, 22, 0.1) 0%, transparent 50%);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-normal);
|
||||
}
|
||||
|
||||
.card-minecraft:hover::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@@ -75,6 +132,10 @@ body {
|
||||
background-color: #1f2937;
|
||||
border-color: #c2410c;
|
||||
}
|
||||
|
||||
.card-minecraft:hover {
|
||||
border-color: var(--primary-orange);
|
||||
}
|
||||
}
|
||||
|
||||
.text-gradient {
|
||||
@@ -82,10 +143,18 @@ body {
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
color: transparent;
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.text-gradient:hover {
|
||||
background: linear-gradient(to right, #f97316, #ea580c);
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
}
|
||||
|
||||
.bg-gradient-carrot {
|
||||
background: linear-gradient(to bottom right, #fb923c, #f97316, #ea580c);
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
/* 现代布局解决方案 */
|
||||
@@ -104,4 +173,277 @@ body {
|
||||
.min-h-screen-nav {
|
||||
min-height: calc(100vh - var(--navbar-height));
|
||||
}
|
||||
|
||||
/* 增强的过渡效果 */
|
||||
.transition-all-enhanced {
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.transition-colors-enhanced {
|
||||
transition: color var(--transition-normal), background-color var(--transition-normal), border-color var(--transition-normal);
|
||||
}
|
||||
|
||||
.transition-transform-enhanced {
|
||||
transition: transform var(--transition-normal);
|
||||
}
|
||||
|
||||
/* 微交互效果 */
|
||||
.micro-interaction {
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.micro-interaction:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.micro-interaction:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.animate-pulse-slow {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
.animate-pulse-fast {
|
||||
animation: pulse 1s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
/* 弹跳动画 */
|
||||
.animate-bounce-slow {
|
||||
animation: bounce 2s infinite;
|
||||
}
|
||||
|
||||
.animate-bounce-fast {
|
||||
animation: bounce 1s infinite;
|
||||
}
|
||||
|
||||
/* 旋转动画 */
|
||||
.animate-spin-slow {
|
||||
animation: spin 3s linear infinite;
|
||||
}
|
||||
|
||||
.animate-spin-fast {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* 渐变动画 */
|
||||
.animate-gradient {
|
||||
background-size: 200% 200%;
|
||||
animation: gradient 3s ease infinite;
|
||||
}
|
||||
|
||||
/* 阴影动画 */
|
||||
.shadow-animated {
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
transition: box-shadow var(--transition-normal);
|
||||
}
|
||||
|
||||
.shadow-animated:hover {
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* 模糊动画 */
|
||||
.backdrop-blur-animated {
|
||||
backdrop-filter: blur(8px);
|
||||
transition: backdrop-filter var(--transition-normal);
|
||||
}
|
||||
|
||||
.backdrop-blur-animated:hover {
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
}
|
||||
|
||||
/* 自定义关键帧动画 */
|
||||
@keyframes gradient {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInUp {
|
||||
from {
|
||||
transform: translateY(30px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInDown {
|
||||
from {
|
||||
transform: translateY(-30px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInLeft {
|
||||
from {
|
||||
transform: translateX(-30px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(30px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
transform: scale(0.9);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scaleOut {
|
||||
from {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: scale(0.9);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 动画工具类 */
|
||||
.animate-slide-in-up {
|
||||
animation: slideInUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-in-down {
|
||||
animation: slideInDown 0.3s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-in-left {
|
||||
animation: slideInLeft 0.3s ease-out;
|
||||
}
|
||||
|
||||
.animate-slide-in-right {
|
||||
animation: slideInRight 0.3s ease-out;
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: scaleIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.animate-scale-out {
|
||||
animation: scaleOut 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* 加载状态样式 */
|
||||
.loading-shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#f0f0f0 0%,
|
||||
#e0e0e0 50%,
|
||||
#f0f0f0 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
.dark .loading-shimmer {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#374151 0%,
|
||||
#4b5563 50%,
|
||||
#374151 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
/* 焦点样式 */
|
||||
.focus-visible-enhanced {
|
||||
outline: 2px solid var(--primary-orange);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: var(--primary-orange);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--primary-orange-dark);
|
||||
}
|
||||
|
||||
/* 响应式动效 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 触摸设备优化 */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.btn-carrot:hover,
|
||||
.btn-carrot-outline:hover,
|
||||
.card-minecraft:hover {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-carrot:active,
|
||||
.btn-carrot-outline:active,
|
||||
.card-minecraft:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import "./globals.css";
|
||||
import Navbar from "@/components/Navbar";
|
||||
import { AuthProvider } from "@/contexts/AuthContext";
|
||||
import { MainContent } from "@/components/MainContent";
|
||||
import { MessageNotificationContainer } from "@/components/MessageNotification";
|
||||
import { ErrorNotificationContainer } from "@/components/ErrorNotification";
|
||||
import ScrollToTop from "@/components/ScrollToTop";
|
||||
import PageTransition from "@/components/PageTransition";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
@@ -35,8 +37,11 @@ export default function RootLayout({
|
||||
<body className={inter.className}>
|
||||
<AuthProvider>
|
||||
<Navbar />
|
||||
<MainContent>{children}</MainContent>
|
||||
<PageTransition>
|
||||
<MainContent>{children}</MainContent>
|
||||
</PageTransition>
|
||||
<ErrorNotificationContainer />
|
||||
<MessageNotificationContainer />
|
||||
<ScrollToTop />
|
||||
</AuthProvider>
|
||||
</body>
|
||||
|
||||
@@ -1,108 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
import { HomeIcon, ArrowLeftIcon } from '@heroicons/react/24/outline';
|
||||
import { NotFoundPage } from '@/components/ErrorPage';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-orange-50 via-white to-amber-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
|
||||
<motion.div
|
||||
className="text-center px-4"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, ease: 'easeOut' }}
|
||||
>
|
||||
{/* 404 数字 */}
|
||||
<motion.div
|
||||
className="mb-8"
|
||||
initial={{ scale: 0.5, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 0.2, duration: 0.6, type: 'spring', stiffness: 200 }}
|
||||
>
|
||||
<h1 className="text-9xl font-black bg-gradient-to-r from-orange-400 via-orange-500 to-amber-500 bg-clip-text text-transparent mb-4">
|
||||
404
|
||||
</h1>
|
||||
<div className="w-24 h-1 bg-gradient-to-r from-orange-400 to-amber-500 mx-auto rounded-full" />
|
||||
</motion.div>
|
||||
|
||||
{/* 错误信息 */}
|
||||
<motion.div
|
||||
className="mb-8"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4, duration: 0.6 }}
|
||||
>
|
||||
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
页面不见了
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400 max-w-md mx-auto leading-relaxed">
|
||||
抱歉,我们找不到您要访问的页面。它可能已被移动、删除,或者您输入的链接不正确。
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Minecraft 风格的装饰 */}
|
||||
<motion.div
|
||||
className="mb-8 flex justify-center"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.6, duration: 0.6 }}
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-orange-400 to-amber-500 rounded-lg flex items-center justify-center transform rotate-12 shadow-lg">
|
||||
<span className="text-2xl font-bold text-white">?</span>
|
||||
</div>
|
||||
<div className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 rounded-full flex items-center justify-center animate-pulse">
|
||||
<span className="text-white text-xs font-bold">!</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<motion.div
|
||||
className="flex flex-col sm:flex-row gap-4 justify-center items-center"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.8, duration: 0.6 }}
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center px-6 py-3 bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white font-semibold rounded-xl transition-all duration-200 shadow-lg hover:shadow-xl transform hover:scale-105"
|
||||
>
|
||||
<HomeIcon className="w-5 h-5 mr-2" />
|
||||
返回主页
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="inline-flex items-center px-6 py-3 border-2 border-orange-500 text-orange-500 hover:bg-orange-500 hover:text-white font-semibold rounded-xl transition-all duration-200"
|
||||
>
|
||||
<ArrowLeftIcon className="w-5 h-5 mr-2" />
|
||||
返回上页
|
||||
</button>
|
||||
</motion.div>
|
||||
|
||||
{/* 额外的帮助信息 */}
|
||||
<motion.div
|
||||
className="mt-8 text-sm text-gray-500 dark:text-gray-400"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 1, duration: 0.6 }}
|
||||
>
|
||||
<p>如果问题持续存在,请
|
||||
<Link href="/contact" className="text-orange-500 hover:text-orange-600 underline">
|
||||
联系我们
|
||||
</Link>
|
||||
的支持团队
|
||||
</p>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* 背景装饰 */}
|
||||
<div className="fixed inset-0 -z-10 overflow-hidden">
|
||||
<div className="absolute top-1/4 left-1/4 w-64 h-64 bg-orange-200/20 dark:bg-orange-900/20 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-amber-200/20 dark:bg-amber-900/20 rounded-full blur-3xl" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <NotFoundPage />;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,8 +6,10 @@ import { MagnifyingGlassIcon, EyeIcon, HeartIcon, ArrowDownTrayIcon, SparklesIco
|
||||
import { HeartIcon as HeartIconSolid } from '@heroicons/react/24/solid';
|
||||
import SkinViewer from '@/components/SkinViewer';
|
||||
import SkinDetailModal from '@/components/SkinDetailModal';
|
||||
import SkinCard from '@/components/SkinCard';
|
||||
import { searchTextures, toggleFavorite, type Texture } from '@/lib/api';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { messageManager } from '@/components/MessageNotification';
|
||||
|
||||
export default function SkinsPage() {
|
||||
const [textures, setTextures] = useState<Texture[]>([]);
|
||||
@@ -106,7 +108,7 @@ export default function SkinsPage() {
|
||||
// 处理收藏
|
||||
const handleFavorite = async (textureId: number) => {
|
||||
if (!isAuthenticated) {
|
||||
alert('请先登录');
|
||||
messageManager.warning('请先登录', { duration: 3000 });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -309,130 +311,15 @@ export default function SkinsPage() {
|
||||
const isFavorited = favoritedIds.has(texture.id);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
<SkinCard
|
||||
key={texture.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className="group relative bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 overflow-hidden border border-white/20 dark:border-gray-700/30"
|
||||
>
|
||||
{/* 3D预览区域 - 更紧凑 */}
|
||||
<div className="relative aspect-square bg-gradient-to-br from-orange-50 to-amber-50 dark:from-gray-700 dark:to-gray-600 overflow-hidden">
|
||||
<SkinViewer
|
||||
skinUrl={texture.url}
|
||||
isSlim={texture.is_slim}
|
||||
width={300}
|
||||
height={300}
|
||||
className="w-full h-full"
|
||||
autoRotate={true}
|
||||
walking={false}
|
||||
/>
|
||||
|
||||
{/* 悬停操作按钮 */}
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
|
||||
<div className="flex gap-3">
|
||||
<motion.button
|
||||
onClick={() => handleDetailView(texture)}
|
||||
className="bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white p-3 rounded-full shadow-lg transition-all duration-200"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
title="详细预览"
|
||||
>
|
||||
<EyeIcon className="w-5 h-5" />
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
onClick={() => window.open(texture.url, '_blank')}
|
||||
className="bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white p-3 rounded-full shadow-lg transition-all duration-200"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
title="查看原图"
|
||||
>
|
||||
<ArrowDownTrayIcon className="w-5 h-5" />
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 标签 */}
|
||||
<div className="absolute top-3 right-3 flex gap-1.5">
|
||||
<motion.span
|
||||
className={`px-2 py-1 text-white text-xs rounded-full font-medium backdrop-blur-sm ${
|
||||
texture.type === 'SKIN' ? 'bg-blue-500/80' : 'bg-purple-500/80'
|
||||
}`}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
>
|
||||
{texture.type === 'SKIN' ? '皮肤' : '披风'}
|
||||
</motion.span>
|
||||
{texture.is_slim && (
|
||||
<motion.span
|
||||
className="px-2 py-1 bg-pink-500/80 text-white text-xs rounded-full font-medium backdrop-blur-sm"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
>
|
||||
细臂
|
||||
</motion.span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Texture Info */}
|
||||
<div className="p-4">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white mb-1 truncate">{texture.name}</h3>
|
||||
{texture.description && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2 leading-relaxed">
|
||||
{texture.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<motion.span
|
||||
className="flex items-center space-x-1"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
>
|
||||
<HeartIcon className="w-4 h-4 text-red-400" />
|
||||
<span className="font-medium">{texture.favorite_count}</span>
|
||||
</motion.span>
|
||||
<motion.span
|
||||
className="flex items-center space-x-1"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
>
|
||||
<ArrowDownTrayIcon className="w-4 h-4 text-blue-400" />
|
||||
<span className="font-medium">{texture.download_count}</span>
|
||||
</motion.span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2">
|
||||
<motion.button
|
||||
onClick={() => handleDetailView(texture)}
|
||||
className="flex-1 bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white text-sm py-2 px-3 rounded-lg transition-all duration-200 font-medium shadow-md hover:shadow-lg flex items-center justify-center"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<EyeIcon className="w-4 h-4 mr-1" />
|
||||
详细预览
|
||||
</motion.button>
|
||||
<motion.button
|
||||
onClick={() => handleFavorite(texture.id)}
|
||||
className={`px-3 py-2 border rounded-lg transition-all duration-200 font-medium ${
|
||||
isFavorited
|
||||
? 'bg-gradient-to-r from-red-500 to-pink-500 border-transparent text-white shadow-md'
|
||||
: 'border-orange-500 text-orange-500 hover:bg-gradient-to-r hover:from-orange-500 hover:to-orange-600 hover:text-white hover:border-transparent hover:shadow-md'
|
||||
}`}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
{isFavorited ? (
|
||||
<HeartIconSolid className="w-4 h-4" />
|
||||
) : (
|
||||
<HeartIcon className="w-4 h-4" />
|
||||
)}
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
texture={texture}
|
||||
isFavorited={isFavorited}
|
||||
onViewDetails={handleDetailView}
|
||||
onToggleFavorite={isAuthenticated ? handleFavorite : undefined}
|
||||
onDownload={(texture) => window.open(texture.url, '_blank')}
|
||||
showVisibilityBadge={false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
|
||||
246
src/components/EnhancedButton.tsx
Normal file
246
src/components/EnhancedButton.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
'use client';
|
||||
|
||||
import { motion, MotionProps } from 'framer-motion';
|
||||
import { ReactNode, useState, useEffect } from 'react';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
|
||||
interface EnhancedButtonProps extends Omit<MotionProps, 'onClick'> {
|
||||
children: ReactNode;
|
||||
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void | Promise<void>;
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
fullWidth?: boolean;
|
||||
icon?: ReactNode;
|
||||
iconPosition?: 'left' | 'right';
|
||||
ripple?: boolean;
|
||||
sound?: boolean;
|
||||
haptic?: boolean;
|
||||
className?: string;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
}
|
||||
|
||||
export default function EnhancedButton({
|
||||
children,
|
||||
onClick,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
disabled = false,
|
||||
loading = false,
|
||||
fullWidth = false,
|
||||
icon,
|
||||
iconPosition = 'left',
|
||||
ripple = true,
|
||||
sound = true,
|
||||
haptic = true,
|
||||
className = '',
|
||||
type = 'button',
|
||||
...motionProps
|
||||
}: EnhancedButtonProps) {
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [ripples, setRipples] = useState<Array<{ id: number; x: number; y: number }>>([]);
|
||||
|
||||
// 播放音效
|
||||
const playSound = (type: 'click' | 'success' | 'error' = 'click') => {
|
||||
if (!sound) return;
|
||||
|
||||
try {
|
||||
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const gainNode = audioContext.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(audioContext.destination);
|
||||
|
||||
const frequencies = {
|
||||
click: 800,
|
||||
success: 1000,
|
||||
error: 400
|
||||
};
|
||||
|
||||
oscillator.frequency.setValueAtTime(frequencies[type], audioContext.currentTime);
|
||||
oscillator.type = type === 'error' ? 'sawtooth' : 'sine';
|
||||
|
||||
gainNode.gain.setValueAtTime(0.1, audioContext.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2);
|
||||
|
||||
oscillator.start(audioContext.currentTime);
|
||||
oscillator.stop(audioContext.currentTime + 0.2);
|
||||
} catch (error) {
|
||||
// 忽略音频API错误
|
||||
}
|
||||
};
|
||||
|
||||
// 触觉反馈
|
||||
const triggerHaptic = () => {
|
||||
if (haptic && navigator.vibrate) {
|
||||
navigator.vibrate(50);
|
||||
}
|
||||
};
|
||||
|
||||
// 创建涟漪效果
|
||||
const createRipple = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (!ripple) return;
|
||||
|
||||
const button = e.currentTarget;
|
||||
const rect = button.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
const newRipple = { id: Date.now(), x, y };
|
||||
|
||||
setRipples(prev => [...prev, newRipple]);
|
||||
|
||||
// 移除涟漪
|
||||
setTimeout(() => {
|
||||
setRipples(prev => prev.filter(r => r.id !== newRipple.id));
|
||||
}, 600);
|
||||
};
|
||||
|
||||
// 处理点击事件
|
||||
const handleClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (disabled || loading || isProcessing) return;
|
||||
|
||||
createRipple(e);
|
||||
playSound('click');
|
||||
triggerHaptic();
|
||||
|
||||
if (onClick) {
|
||||
try {
|
||||
setIsProcessing(true);
|
||||
const result = onClick(e);
|
||||
|
||||
// 如果返回的是 Promise,等待它完成
|
||||
if (result instanceof Promise) {
|
||||
await result;
|
||||
playSound('success');
|
||||
}
|
||||
} catch (error) {
|
||||
playSound('error');
|
||||
console.error('Button click error:', error);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getVariantStyles = () => {
|
||||
const baseStyles = 'relative overflow-hidden transition-all duration-200 font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
|
||||
const variants = {
|
||||
primary: 'bg-gradient-to-r from-orange-500 to-amber-500 text-white hover:from-orange-600 hover:to-amber-600 focus:ring-orange-500 shadow-lg hover:shadow-xl',
|
||||
secondary: 'bg-gradient-to-r from-gray-500 to-gray-600 text-white hover:from-gray-600 hover:to-gray-700 focus:ring-gray-500 shadow-md hover:shadow-lg',
|
||||
outline: 'border-2 border-orange-500 text-orange-500 hover:bg-orange-500 hover:text-white focus:ring-orange-500',
|
||||
ghost: 'text-orange-500 hover:bg-orange-100 hover:text-orange-600 focus:ring-orange-500 dark:hover:bg-orange-900/20',
|
||||
danger: 'bg-gradient-to-r from-red-500 to-pink-500 text-white hover:from-red-600 hover:to-pink-600 focus:ring-red-500 shadow-lg hover:shadow-xl'
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-3 py-1.5 text-sm rounded-md',
|
||||
md: 'px-4 py-2 text-base rounded-lg',
|
||||
lg: 'px-6 py-3 text-lg rounded-xl'
|
||||
};
|
||||
|
||||
return `${baseStyles} ${variants[variant]} ${sizes[size]} ${fullWidth ? 'w-full' : ''} ${className}`;
|
||||
};
|
||||
|
||||
const buttonVariants = {
|
||||
initial: { scale: 1 },
|
||||
hover: {
|
||||
scale: 1.02,
|
||||
transition: { duration: 0.2, ease: "easeOut" }
|
||||
},
|
||||
tap: {
|
||||
scale: 0.98,
|
||||
transition: { duration: 0.1 }
|
||||
},
|
||||
loading: {
|
||||
scale: 0.95,
|
||||
transition: { duration: 0.2 }
|
||||
}
|
||||
};
|
||||
|
||||
const isDisabled = disabled || loading || isProcessing;
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
type={type}
|
||||
onClick={handleClick}
|
||||
disabled={isDisabled}
|
||||
className={getVariantStyles()}
|
||||
variants={buttonVariants}
|
||||
initial="initial"
|
||||
whileHover={!isDisabled ? "hover" : undefined}
|
||||
whileTap={!isDisabled ? "tap" : undefined}
|
||||
animate={loading || isProcessing ? "loading" : "initial"}
|
||||
{...motionProps}
|
||||
>
|
||||
{/* 涟漪效果 */}
|
||||
{ripple && (
|
||||
<span className="absolute inset-0 overflow-hidden rounded-inherit">
|
||||
{ripples.map(ripple => (
|
||||
<motion.span
|
||||
key={ripple.id}
|
||||
className="absolute bg-white/30 rounded-full pointer-events-none"
|
||||
style={{
|
||||
left: ripple.x - 10,
|
||||
top: ripple.y - 10,
|
||||
width: 20,
|
||||
height: 20,
|
||||
}}
|
||||
initial={{ scale: 0, opacity: 1 }}
|
||||
animate={{ scale: 4, opacity: 0 }}
|
||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 加载状态 */}
|
||||
<AnimatePresence>
|
||||
{(loading || isProcessing) && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
className="absolute inset-0 flex items-center justify-center bg-inherit rounded-inherit"
|
||||
>
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||
className="w-4 h-4 border-2 border-current border-t-transparent rounded-full"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 按钮内容 */}
|
||||
<motion.span
|
||||
className={`flex items-center justify-center space-x-2 transition-opacity duration-200 ${
|
||||
loading || isProcessing ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
>
|
||||
{icon && iconPosition === 'left' && (
|
||||
<motion.span
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
{icon}
|
||||
</motion.span>
|
||||
)}
|
||||
<span>{children}</span>
|
||||
{icon && iconPosition === 'right' && (
|
||||
<motion.span
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
{icon}
|
||||
</motion.span>
|
||||
)}
|
||||
</motion.span>
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
||||
432
src/components/EnhancedInput.tsx
Normal file
432
src/components/EnhancedInput.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
'use client';
|
||||
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ReactNode, useState, useRef, useEffect, forwardRef } from 'react';
|
||||
import { EyeIcon, EyeSlashIcon, ExclamationCircleIcon, CheckCircleIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
interface EnhancedInputProps {
|
||||
type?: 'text' | 'password' | 'email' | 'number' | 'tel' | 'url' | 'search';
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onFocus?: (e: React.FocusEvent<HTMLInputElement>) => void;
|
||||
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
|
||||
label?: string;
|
||||
error?: string;
|
||||
success?: string;
|
||||
hint?: string;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
leftIcon?: ReactNode;
|
||||
rightIcon?: ReactNode;
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
autoFocus?: boolean;
|
||||
autoComplete?: string;
|
||||
name?: string;
|
||||
id?: string;
|
||||
showPasswordToggle?: boolean;
|
||||
showStrengthIndicator?: boolean;
|
||||
showCharCount?: boolean;
|
||||
maxLength?: number;
|
||||
minLength?: number;
|
||||
pattern?: string;
|
||||
validate?: (value: string) => string | null;
|
||||
onValidationChange?: (isValid: boolean) => void;
|
||||
}
|
||||
|
||||
const EnhancedInput = forwardRef<HTMLInputElement, EnhancedInputProps>(({
|
||||
type = 'text',
|
||||
placeholder,
|
||||
value = '',
|
||||
onChange,
|
||||
onFocus,
|
||||
onBlur,
|
||||
label,
|
||||
error,
|
||||
success,
|
||||
hint,
|
||||
disabled = false,
|
||||
required = false,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
className = '',
|
||||
containerClassName = '',
|
||||
autoFocus = false,
|
||||
autoComplete,
|
||||
name,
|
||||
id,
|
||||
showPasswordToggle = false,
|
||||
showStrengthIndicator = false,
|
||||
showCharCount = false,
|
||||
maxLength,
|
||||
minLength,
|
||||
pattern,
|
||||
validate,
|
||||
onValidationChange,
|
||||
...props
|
||||
}, ref) => {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||
const [internalValue, setInternalValue] = useState(value);
|
||||
const [validationMessage, setValidationMessage] = useState<string | null>(null);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 同步外部值
|
||||
useEffect(() => {
|
||||
setInternalValue(value);
|
||||
}, [value]);
|
||||
|
||||
// 验证逻辑
|
||||
useEffect(() => {
|
||||
if (validate && internalValue) {
|
||||
setIsValidating(true);
|
||||
const message = validate(internalValue);
|
||||
setValidationMessage(message);
|
||||
setIsValidating(false);
|
||||
|
||||
if (onValidationChange) {
|
||||
onValidationChange(!message);
|
||||
}
|
||||
}
|
||||
}, [internalValue, validate, onValidationChange]);
|
||||
|
||||
// 密码强度计算
|
||||
const getPasswordStrength = (password: string) => {
|
||||
let strength = 0;
|
||||
if (password.length >= 8) strength++;
|
||||
if (/[a-z]/.test(password)) strength++;
|
||||
if (/[A-Z]/.test(password)) strength++;
|
||||
if (/[0-9]/.test(password)) strength++;
|
||||
if (/[^A-Za-z0-9]/.test(password)) strength++;
|
||||
return strength;
|
||||
};
|
||||
|
||||
const passwordStrength = type === 'password' && showStrengthIndicator ? getPasswordStrength(internalValue) : 0;
|
||||
|
||||
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
setIsFocused(true);
|
||||
onFocus?.(e);
|
||||
};
|
||||
|
||||
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
setIsFocused(false);
|
||||
onBlur?.(e);
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
setInternalValue(newValue);
|
||||
onChange?.(e);
|
||||
};
|
||||
|
||||
const togglePasswordVisibility = () => {
|
||||
setIsPasswordVisible(!isPasswordVisible);
|
||||
};
|
||||
|
||||
const getInputType = () => {
|
||||
if (type === 'password' && showPasswordToggle) {
|
||||
return isPasswordVisible ? 'text' : 'password';
|
||||
}
|
||||
return type;
|
||||
};
|
||||
|
||||
const getBorderColor = () => {
|
||||
if (error || validationMessage) return 'border-red-500 focus:border-red-500 focus:ring-red-500';
|
||||
if (success || (validationMessage === null && internalValue && validate)) return 'border-green-500 focus:border-green-500 focus:ring-green-500';
|
||||
if (isFocused) return 'border-orange-500 focus:border-orange-500 focus:ring-orange-500';
|
||||
return 'border-gray-300 dark:border-gray-600 focus:border-orange-500 focus:ring-orange-500';
|
||||
};
|
||||
|
||||
const getLabelColor = () => {
|
||||
if (error || validationMessage) return 'text-red-600 dark:text-red-400';
|
||||
if (success || (validationMessage === null && internalValue && validate)) return 'text-green-600 dark:text-green-400';
|
||||
if (isFocused) return 'text-orange-600 dark:text-orange-400';
|
||||
return 'text-gray-700 dark:text-gray-300';
|
||||
};
|
||||
|
||||
const inputVariants = {
|
||||
initial: { scale: 1 },
|
||||
focus: {
|
||||
scale: 1.02,
|
||||
transition: { duration: 0.2, ease: "easeOut" }
|
||||
},
|
||||
blur: {
|
||||
scale: 1,
|
||||
transition: { duration: 0.2, ease: "easeOut" }
|
||||
}
|
||||
};
|
||||
|
||||
const labelVariants = {
|
||||
initial: { y: 0, scale: 1 },
|
||||
focus: {
|
||||
y: -20,
|
||||
scale: 0.85,
|
||||
transition: { duration: 0.2, ease: "easeOut" }
|
||||
},
|
||||
blur: {
|
||||
y: internalValue ? -20 : 0,
|
||||
scale: internalValue ? 0.85 : 1,
|
||||
transition: { duration: 0.2, ease: "easeOut" }
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={containerRef}
|
||||
className={`relative ${containerClassName}`}
|
||||
initial="initial"
|
||||
animate={isFocused ? "focus" : "blur"}
|
||||
variants={inputVariants}
|
||||
>
|
||||
{/* 标签 */}
|
||||
{label && (
|
||||
<motion.label
|
||||
htmlFor={id}
|
||||
className={`absolute left-3 z-10 bg-white dark:bg-gray-800 px-1 text-sm font-medium transition-all duration-200 ${
|
||||
isFocused || internalValue ? 'text-xs' : 'text-base'
|
||||
} ${getLabelColor()}`}
|
||||
initial="initial"
|
||||
animate={isFocused ? "focus" : "blur"}
|
||||
variants={labelVariants}
|
||||
>
|
||||
{label}
|
||||
{required && <span className="text-red-500 ml-1">*</span>}
|
||||
</motion.label>
|
||||
)}
|
||||
|
||||
{/* 输入框容器 */}
|
||||
<motion.div
|
||||
className={`relative flex items-center bg-white dark:bg-gray-800 border-2 rounded-lg transition-all duration-200 ${
|
||||
getBorderColor()
|
||||
} ${disabled ? 'bg-gray-100 dark:bg-gray-700 cursor-not-allowed' : ''}`}
|
||||
whileHover={!disabled ? { scale: 1.01 } : {}}
|
||||
animate={{
|
||||
boxShadow: isFocused ? '0 0 0 3px rgba(249, 115, 22, 0.1)' : '0 1px 3px 0 rgba(0, 0, 0, 0.1)'
|
||||
}}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{/* 左侧图标 */}
|
||||
{leftIcon && (
|
||||
<motion.div
|
||||
className="absolute left-3 text-gray-400 dark:text-gray-500"
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
{leftIcon}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 输入框 */}
|
||||
<input
|
||||
ref={ref || inputRef}
|
||||
type={getInputType()}
|
||||
placeholder={!label ? placeholder : ''}
|
||||
value={internalValue}
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
autoFocus={autoFocus}
|
||||
autoComplete={autoComplete}
|
||||
name={name}
|
||||
id={id}
|
||||
maxLength={maxLength}
|
||||
minLength={minLength}
|
||||
pattern={pattern}
|
||||
className={`w-full bg-transparent border-0 outline-none focus:ring-0 transition-all duration-200 ${
|
||||
leftIcon ? 'pl-10' : 'pl-4'
|
||||
} ${
|
||||
rightIcon || (type === 'password' && showPasswordToggle) ? 'pr-10' : 'pr-4'
|
||||
} ${
|
||||
size === 'sm' ? 'py-2 text-sm' : size === 'lg' ? 'py-3 text-lg' : 'py-2.5 text-base'
|
||||
} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
{/* 验证状态图标 */}
|
||||
<AnimatePresence>
|
||||
{isValidating && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
className="absolute right-3 text-orange-500"
|
||||
>
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||
className="w-4 h-4 border-2 border-orange-500 border-t-transparent rounded-full"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{!isValidating && validationMessage === null && internalValue && validate && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
className="absolute right-3 text-green-500"
|
||||
>
|
||||
<CheckCircleIcon className="w-5 h-5" />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{(error || validationMessage) && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
className="absolute right-3 text-red-500"
|
||||
>
|
||||
<ExclamationCircleIcon className="w-5 h-5" />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 右侧图标和密码切换 */}
|
||||
<div className="absolute right-3 flex items-center space-x-2">
|
||||
{type === 'password' && showPasswordToggle && (
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={togglePasswordVisibility}
|
||||
className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition-colors duration-200"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
{isPasswordVisible ? <EyeSlashIcon className="w-5 h-5" /> : <EyeIcon className="w-5 h-5" />}
|
||||
</motion.button>
|
||||
)}
|
||||
|
||||
{rightIcon && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
{rightIcon}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 密码强度指示器 */}
|
||||
{type === 'password' && showStrengthIndicator && internalValue && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="mt-2"
|
||||
>
|
||||
<div className="flex space-x-1">
|
||||
{[1, 2, 3, 4, 5].map((level) => (
|
||||
<motion.div
|
||||
key={level}
|
||||
className={`h-1 flex-1 rounded-full transition-all duration-300 ${
|
||||
passwordStrength >= level
|
||||
? level <= 2
|
||||
? 'bg-red-500'
|
||||
: level <= 3
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-green-500'
|
||||
: 'bg-gray-200 dark:bg-gray-700'
|
||||
}`}
|
||||
initial={{ scaleX: 0 }}
|
||||
animate={{ scaleX: passwordStrength >= level ? 1 : 0.3 }}
|
||||
transition={{ delay: level * 0.1 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<motion.p
|
||||
className="text-xs mt-1 text-gray-600 dark:text-gray-400"
|
||||
initial={{ opacity: 0, y: -5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
{passwordStrength <= 2 && '密码强度:弱'}
|
||||
{passwordStrength === 3 && '密码强度:中等'}
|
||||
{passwordStrength >= 4 && '密码强度:强'}
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 字符计数 */}
|
||||
{showCharCount && maxLength && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex justify-between items-center mt-1"
|
||||
>
|
||||
<div />
|
||||
<motion.span
|
||||
className={`text-xs ${
|
||||
internalValue.length > maxLength
|
||||
? 'text-red-500'
|
||||
: internalValue.length > maxLength * 0.8
|
||||
? 'text-yellow-500'
|
||||
: 'text-gray-500'
|
||||
}`}
|
||||
animate={{
|
||||
scale: internalValue.length > maxLength ? 1.1 : 1,
|
||||
color: internalValue.length > maxLength ? '#ef4444' : internalValue.length > maxLength * 0.8 ? '#f59e0b' : '#6b7280'
|
||||
}}
|
||||
>
|
||||
{internalValue.length}/{maxLength}
|
||||
</motion.span>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 错误信息 */}
|
||||
<AnimatePresence>
|
||||
{(error || validationMessage) && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0, y: -10 }}
|
||||
animate={{ opacity: 1, height: 'auto', y: 0 }}
|
||||
exit={{ opacity: 0, height: 0, y: -10 }}
|
||||
className="mt-1 text-sm text-red-600 dark:text-red-400 flex items-center space-x-1"
|
||||
>
|
||||
<ExclamationCircleIcon className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{error || validationMessage}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 成功信息 */}
|
||||
<AnimatePresence>
|
||||
{success && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0, y: -10 }}
|
||||
animate={{ opacity: 1, height: 'auto', y: 0 }}
|
||||
exit={{ opacity: 0, height: 0, y: -10 }}
|
||||
className="mt-1 text-sm text-green-600 dark:text-green-400 flex items-center space-x-1"
|
||||
>
|
||||
<CheckCircleIcon className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{success}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 提示信息 */}
|
||||
<AnimatePresence>
|
||||
{hint && !error && !validationMessage && !success && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0, y: -10 }}
|
||||
animate={{ opacity: 1, height: 'auto', y: 0 }}
|
||||
exit={{ opacity: 0, height: 0, y: -10 }}
|
||||
className="mt-1 text-sm text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
{hint}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
});
|
||||
|
||||
EnhancedInput.displayName = 'EnhancedInput';
|
||||
|
||||
export default EnhancedInput;
|
||||
@@ -15,16 +15,26 @@ interface ErrorNotificationProps {
|
||||
|
||||
export function ErrorNotification({ message, type = 'error', duration = 5000, onClose }: ErrorNotificationProps) {
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [progress, setProgress] = useState(100);
|
||||
|
||||
useEffect(() => {
|
||||
if (duration > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
onClose?.();
|
||||
}, duration);
|
||||
return () => clearTimeout(timer);
|
||||
if (duration > 0 && !isHovered) {
|
||||
const startTime = Date.now();
|
||||
const timer = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const remaining = Math.max(0, duration - elapsed);
|
||||
setProgress((remaining / duration) * 100);
|
||||
|
||||
if (remaining === 0) {
|
||||
setIsVisible(false);
|
||||
onClose?.();
|
||||
}
|
||||
}, 50);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
}, [duration, onClose]);
|
||||
}, [duration, onClose, isHovered]);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsVisible(false);
|
||||
@@ -52,7 +62,8 @@ export function ErrorNotification({ message, type = 'error', duration = 5000, on
|
||||
border: 'border-red-200 dark:border-red-800',
|
||||
text: 'text-red-800 dark:text-red-200',
|
||||
icon: 'text-red-500',
|
||||
close: 'text-red-400 hover:text-red-600 dark:text-red-300 dark:hover:text-red-100'
|
||||
close: 'text-red-400 hover:text-red-600 dark:text-red-300 dark:hover:text-red-100',
|
||||
progress: 'bg-red-500'
|
||||
};
|
||||
case 'warning':
|
||||
return {
|
||||
@@ -60,7 +71,8 @@ export function ErrorNotification({ message, type = 'error', duration = 5000, on
|
||||
border: 'border-yellow-200 dark:border-yellow-800',
|
||||
text: 'text-yellow-800 dark:text-yellow-200',
|
||||
icon: 'text-yellow-500',
|
||||
close: 'text-yellow-400 hover:text-yellow-600 dark:text-yellow-300 dark:hover:text-yellow-100'
|
||||
close: 'text-yellow-400 hover:text-yellow-600 dark:text-yellow-300 dark:hover:text-yellow-100',
|
||||
progress: 'bg-yellow-500'
|
||||
};
|
||||
case 'success':
|
||||
return {
|
||||
@@ -68,7 +80,8 @@ export function ErrorNotification({ message, type = 'error', duration = 5000, on
|
||||
border: 'border-green-200 dark:border-green-800',
|
||||
text: 'text-green-800 dark:text-green-200',
|
||||
icon: 'text-green-500',
|
||||
close: 'text-green-400 hover:text-green-600 dark:text-green-300 dark:hover:text-green-100'
|
||||
close: 'text-green-400 hover:text-green-600 dark:text-green-300 dark:hover:text-green-100',
|
||||
progress: 'bg-green-500'
|
||||
};
|
||||
case 'info':
|
||||
return {
|
||||
@@ -76,38 +89,98 @@ export function ErrorNotification({ message, type = 'error', duration = 5000, on
|
||||
border: 'border-blue-200 dark:border-blue-800',
|
||||
text: 'text-blue-800 dark:text-blue-200',
|
||||
icon: 'text-blue-500',
|
||||
close: 'text-blue-400 hover:text-blue-600 dark:text-blue-300 dark:hover:text-blue-100'
|
||||
close: 'text-blue-400 hover:text-blue-600 dark:text-blue-300 dark:hover:text-blue-100',
|
||||
progress: 'bg-blue-500'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const styles = getStyles();
|
||||
|
||||
const getAnimationVariants = () => {
|
||||
switch (type) {
|
||||
case 'error':
|
||||
return {
|
||||
initial: { opacity: 0, x: 100, scale: 0.8, rotate: -5 },
|
||||
animate: { opacity: 1, x: 0, scale: 1, rotate: 0 },
|
||||
exit: { opacity: 0, x: 100, scale: 0.8, rotate: 5 },
|
||||
};
|
||||
case 'warning':
|
||||
return {
|
||||
initial: { opacity: 0, y: -20, scale: 0.9 },
|
||||
animate: { opacity: 1, y: 0, scale: 1 },
|
||||
exit: { opacity: 0, y: -20, scale: 0.9 },
|
||||
};
|
||||
case 'success':
|
||||
return {
|
||||
initial: { opacity: 0, scale: 0.5 },
|
||||
animate: { opacity: 1, scale: 1 },
|
||||
exit: { opacity: 0, scale: 0.5 },
|
||||
};
|
||||
case 'info':
|
||||
return {
|
||||
initial: { opacity: 0, x: -20, scale: 0.9 },
|
||||
animate: { opacity: 1, x: 0, scale: 1 },
|
||||
exit: { opacity: 0, x: -20, scale: 0.9 },
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isVisible && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20, scale: 0.9 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -20, scale: 0.9 }}
|
||||
transition={{ duration: 0.3, ease: 'easeOut' }}
|
||||
className={`fixed top-4 right-4 z-50 max-w-sm w-full ${styles.bg} ${styles.border} border rounded-xl shadow-lg backdrop-blur-sm`}
|
||||
{...getAnimationVariants()}
|
||||
transition={{
|
||||
duration: 0.4,
|
||||
ease: 'easeOut',
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 20
|
||||
}}
|
||||
className={`fixed top-4 right-4 z-[9999] max-w-lg w-full ${styles.bg} ${styles.border} border rounded-xl shadow-2xl backdrop-blur-lg overflow-hidden`}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
whileHover={{ scale: 1.02, y: -2 }}
|
||||
>
|
||||
{/* Progress bar */}
|
||||
<div className="absolute bottom-0 left-0 h-1 bg-gray-200/50 dark:bg-gray-700/50 w-full">
|
||||
<motion.div
|
||||
className={`h-full ${styles.progress}`}
|
||||
initial={{ width: '100%' }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ duration: 0.1, ease: 'linear' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start p-4">
|
||||
<div className={`flex-shrink-0 ${styles.icon} mr-3 mt-0.5`}>
|
||||
<motion.div
|
||||
className={`flex-shrink-0 ${styles.icon} mr-3 mt-0.5`}
|
||||
animate={{
|
||||
scale: type === 'error' ? [1, 1.1, 1] : 1,
|
||||
rotate: type === 'warning' ? [0, -5, 5, 0] : 0
|
||||
}}
|
||||
transition={{
|
||||
duration: type === 'error' ? 0.5 : 0.3,
|
||||
repeat: type === 'error' ? Infinity : 0,
|
||||
repeatDelay: 2
|
||||
}}
|
||||
>
|
||||
{getIcon()}
|
||||
</div>
|
||||
</motion.div>
|
||||
<div className="flex-1">
|
||||
<p className={`text-sm font-medium ${styles.text}`}>
|
||||
<p className={`text-sm font-medium ${styles.text} leading-relaxed`}>
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
<motion.button
|
||||
onClick={handleClose}
|
||||
className={`flex-shrink-0 ml-3 ${styles.close} transition-colors`}
|
||||
className={`flex-shrink-0 ml-3 ${styles.close} transition-all duration-200 p-1 rounded-full hover:bg-white/20 dark:hover:bg-black/20`}
|
||||
whileHover={{ scale: 1.1, rotate: 90 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</motion.button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
@@ -115,10 +188,11 @@ export function ErrorNotification({ message, type = 'error', duration = 5000, on
|
||||
);
|
||||
}
|
||||
|
||||
// 全局错误管理器
|
||||
// 增强的全局错误管理器
|
||||
class ErrorManager {
|
||||
private static instance: ErrorManager;
|
||||
private listeners: Array<(notification: ErrorNotificationProps & { id: string }) => void> = [];
|
||||
private soundEnabled: boolean = true;
|
||||
|
||||
static getInstance(): ErrorManager {
|
||||
if (!ErrorManager.instance) {
|
||||
@@ -127,19 +201,51 @@ class ErrorManager {
|
||||
return ErrorManager.instance;
|
||||
}
|
||||
|
||||
private playSound(type: ErrorType) {
|
||||
if (!this.soundEnabled) return;
|
||||
|
||||
// 创建音频反馈
|
||||
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const gainNode = audioContext.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(audioContext.destination);
|
||||
|
||||
const frequencies = {
|
||||
error: 300,
|
||||
warning: 400,
|
||||
success: 600,
|
||||
info: 500
|
||||
};
|
||||
|
||||
oscillator.frequency.setValueAtTime(frequencies[type], audioContext.currentTime);
|
||||
oscillator.type = type === 'error' ? 'sawtooth' : 'sine';
|
||||
|
||||
gainNode.gain.setValueAtTime(0.1, audioContext.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3);
|
||||
|
||||
oscillator.start(audioContext.currentTime);
|
||||
oscillator.stop(audioContext.currentTime + 0.3);
|
||||
}
|
||||
|
||||
showError(message: string, duration?: number) {
|
||||
this.playSound('error');
|
||||
this.showNotification(message, 'error', duration);
|
||||
}
|
||||
|
||||
showWarning(message: string, duration?: number) {
|
||||
this.playSound('warning');
|
||||
this.showNotification(message, 'warning', duration);
|
||||
}
|
||||
|
||||
showSuccess(message: string, duration?: number) {
|
||||
this.playSound('success');
|
||||
this.showNotification(message, 'success', duration);
|
||||
}
|
||||
|
||||
showInfo(message: string, duration?: number) {
|
||||
this.playSound('info');
|
||||
this.showNotification(message, 'info', duration);
|
||||
}
|
||||
|
||||
@@ -160,13 +266,18 @@ class ErrorManager {
|
||||
this.listeners = this.listeners.filter(l => l !== listener);
|
||||
};
|
||||
}
|
||||
|
||||
setSoundEnabled(enabled: boolean) {
|
||||
this.soundEnabled = enabled;
|
||||
}
|
||||
}
|
||||
|
||||
export const errorManager = ErrorManager.getInstance();
|
||||
|
||||
// 错误提示容器组件
|
||||
// 增强的错误提示容器组件
|
||||
export function ErrorNotificationContainer() {
|
||||
const [notifications, setNotifications] = useState<Array<ErrorNotificationProps & { id: string }>>([]);
|
||||
const [soundEnabled, setSoundEnabled] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = errorManager.subscribe((notification) => {
|
||||
@@ -176,19 +287,30 @@ export function ErrorNotificationContainer() {
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
errorManager.setSoundEnabled(soundEnabled);
|
||||
}, [soundEnabled]);
|
||||
|
||||
const removeNotification = (id: string) => {
|
||||
setNotifications(prev => prev.filter(n => n.id !== id));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{notifications.map((notification) => (
|
||||
<ErrorNotification
|
||||
{notifications.map((notification, index) => (
|
||||
<motion.div
|
||||
key={notification.id}
|
||||
{...notification}
|
||||
onClose={() => removeNotification(notification.id)}
|
||||
/>
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
>
|
||||
<ErrorNotification
|
||||
{...notification}
|
||||
onClose={() => removeNotification(notification.id)}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
import { motion, AnimatePresence, useScroll, useTransform } from 'framer-motion';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
HomeIcon,
|
||||
ArrowLeftIcon,
|
||||
@@ -9,8 +10,15 @@ import {
|
||||
XCircleIcon,
|
||||
ClockIcon,
|
||||
ServerIcon,
|
||||
WifiIcon
|
||||
WifiIcon,
|
||||
ClipboardDocumentIcon,
|
||||
ArrowPathIcon,
|
||||
CubeIcon,
|
||||
QuestionMarkCircleIcon,
|
||||
SparklesIcon,
|
||||
RocketLaunchIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { messageManager } from './MessageNotification';
|
||||
|
||||
export interface ErrorPageProps {
|
||||
code?: number;
|
||||
@@ -31,47 +39,122 @@ export interface ErrorPageProps {
|
||||
};
|
||||
};
|
||||
showContact?: boolean;
|
||||
showRetry?: boolean;
|
||||
onRetry?: () => void;
|
||||
showCopyError?: boolean;
|
||||
errorDetails?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const errorConfigs = {
|
||||
'404': {
|
||||
icon: <XCircleIcon className="w-16 h-16" />,
|
||||
title: '页面不见了',
|
||||
message: '抱歉,我们找不到您要访问的页面。',
|
||||
description: '它可能已被移动、删除,或者您输入的链接不正确。'
|
||||
icon: <CubeIcon className="w-20 h-20" />,
|
||||
title: '页面未找到',
|
||||
message: '这个页面似乎不存在于我们的世界中',
|
||||
description: '页面可能已被移除、重命名,或者您输入的地址不正确。',
|
||||
suggestions: [
|
||||
'检查网址拼写是否正确',
|
||||
'返回主页重新探索',
|
||||
'使用搜索功能寻找内容'
|
||||
]
|
||||
},
|
||||
'500': {
|
||||
icon: <ServerIcon className="w-16 h-16" />,
|
||||
icon: <ServerIcon className="w-20 h-20" />,
|
||||
title: '服务器错误',
|
||||
message: '抱歉,服务器遇到了一些问题。',
|
||||
description: '我们的团队正在努力解决这个问题,请稍后再试。'
|
||||
message: '我们的服务器遇到了一些技术问题',
|
||||
description: '工程师们正在紧急修复中,请稍后再试。',
|
||||
suggestions: [
|
||||
'稍后刷新页面重试',
|
||||
'清除浏览器缓存',
|
||||
'检查网络连接'
|
||||
]
|
||||
},
|
||||
'403': {
|
||||
icon: <ExclamationTriangleIcon className="w-16 h-16" />,
|
||||
icon: <ExclamationTriangleIcon className="w-20 h-20" />,
|
||||
title: '访问被拒绝',
|
||||
message: '抱歉,您没有权限访问此页面。',
|
||||
description: '请检查您的账户权限或联系管理员。'
|
||||
message: '您没有权限进入这个区域',
|
||||
description: '请检查您的权限等级或联系管理员获取访问权限。',
|
||||
suggestions: [
|
||||
'确认您是否已登录',
|
||||
'检查账户权限等级',
|
||||
'联系管理员申请权限'
|
||||
]
|
||||
},
|
||||
'network': {
|
||||
icon: <WifiIcon className="w-16 h-16" />,
|
||||
title: '网络连接错误',
|
||||
message: '无法连接到服务器。',
|
||||
description: '请检查您的网络连接,然后重试。'
|
||||
network: {
|
||||
icon: <WifiIcon className="w-20 h-20" />,
|
||||
title: '网络连接问题',
|
||||
message: '与我们的连接出现了问题',
|
||||
description: '请检查您的网络连接,然后重新尝试。',
|
||||
suggestions: [
|
||||
'检查网络连接状态',
|
||||
'尝试重新连接',
|
||||
'检查防火墙设置'
|
||||
]
|
||||
},
|
||||
'timeout': {
|
||||
icon: <ClockIcon className="w-16 h-16" />,
|
||||
title: '请求超时',
|
||||
message: '请求处理时间过长。',
|
||||
description: '请刷新页面或稍后再试。'
|
||||
timeout: {
|
||||
icon: <ClockIcon className="w-20 h-20" />,
|
||||
title: '连接超时',
|
||||
message: '服务器响应时间过长',
|
||||
description: '服务器响应缓慢,请稍后再试。',
|
||||
suggestions: [
|
||||
'检查网络连接状态',
|
||||
'稍后重新尝试连接',
|
||||
'联系技术支持团队'
|
||||
]
|
||||
},
|
||||
'maintenance': {
|
||||
icon: <ServerIcon className="w-16 h-16" />,
|
||||
maintenance: {
|
||||
icon: <ServerIcon className="w-20 h-20" />,
|
||||
title: '系统维护中',
|
||||
message: '我们正在进行系统维护。',
|
||||
description: '请稍后再试,我们会尽快恢复服务。'
|
||||
message: '我们正在对系统进行升级改造',
|
||||
description: '为了提供更好的体验,系统暂时关闭维护。',
|
||||
suggestions: [
|
||||
'关注官方公告获取开放时间',
|
||||
'加入官方群组了解进度',
|
||||
'稍后再试'
|
||||
]
|
||||
},
|
||||
custom: {
|
||||
icon: <QuestionMarkCircleIcon className="w-20 h-20" />,
|
||||
title: '未知错误',
|
||||
message: '发生了一些奇怪的事情',
|
||||
description: '请稍后再试或联系我们的支持团队。',
|
||||
suggestions: []
|
||||
}
|
||||
};
|
||||
|
||||
// Action Button Component
|
||||
function ActionButton({ action, colorClass, primary = false }: {
|
||||
action: { label: string; href?: string; onClick?: () => void };
|
||||
colorClass?: string;
|
||||
primary?: boolean;
|
||||
}) {
|
||||
const buttonContent = (
|
||||
<>
|
||||
{primary ? <HomeIcon className="w-5 h-5 mr-2" /> : <ArrowLeftIcon className="w-5 h-5 mr-2" />}
|
||||
{action.label}
|
||||
</>
|
||||
);
|
||||
|
||||
const buttonClass = primary
|
||||
? `inline-flex items-center justify-center px-6 py-4 bg-gradient-to-r ${colorClass} text-white font-semibold rounded-xl transition-all duration-200 shadow-lg hover:shadow-xl transform hover:scale-105`
|
||||
: 'inline-flex items-center justify-center px-6 py-4 border-2 border-orange-500 text-orange-500 hover:bg-orange-500 hover:text-white font-semibold rounded-xl transition-all duration-200';
|
||||
|
||||
if ('href' in action && action.href) {
|
||||
return (
|
||||
<Link href={action.href} className={buttonClass}>
|
||||
{buttonContent}
|
||||
</Link>
|
||||
);
|
||||
} else if ('onClick' in action && action.onClick) {
|
||||
return (
|
||||
<button onClick={action.onClick} className={buttonClass}>
|
||||
{buttonContent}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ErrorPage({
|
||||
code,
|
||||
title,
|
||||
@@ -79,176 +162,372 @@ export function ErrorPage({
|
||||
description,
|
||||
type = 'custom',
|
||||
actions,
|
||||
showContact = true
|
||||
showContact = true,
|
||||
showRetry = true,
|
||||
onRetry,
|
||||
showCopyError = true,
|
||||
errorDetails,
|
||||
className = ''
|
||||
}: ErrorPageProps) {
|
||||
const [isRetrying, setIsRetrying] = useState(false);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [copySuccess, setCopySuccess] = useState(false);
|
||||
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
|
||||
const { scrollYProgress } = useScroll();
|
||||
const opacity = useTransform(scrollYProgress, [0, 0.3], [1, 0.8]);
|
||||
|
||||
const config = errorConfigs[type] || {};
|
||||
const displayTitle = title || config.title || '出错了';
|
||||
const displayMessage = message || config.message || '发生了一些错误';
|
||||
const displayDescription = description || config.description || '';
|
||||
|
||||
// 生成详细的错误信息
|
||||
const generateErrorDetails = useCallback(() => {
|
||||
const details = {
|
||||
timestamp: new Date().toISOString(),
|
||||
errorType: type,
|
||||
errorCode: code,
|
||||
userAgent: typeof window !== 'undefined' ? window.navigator.userAgent : 'Unknown',
|
||||
url: typeof window !== 'undefined' ? window.location.href : 'Unknown',
|
||||
customDetails: errorDetails
|
||||
};
|
||||
return JSON.stringify(details, null, 2);
|
||||
}, [type, code, errorDetails]);
|
||||
|
||||
const defaultActions = {
|
||||
primary: {
|
||||
label: '返回主页',
|
||||
label: '返回主城',
|
||||
href: '/'
|
||||
},
|
||||
secondary: {
|
||||
label: '返回上页',
|
||||
onClick: () => window.history.back()
|
||||
onClick: () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.history.back();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const finalActions = { ...defaultActions, ...actions };
|
||||
|
||||
const getThemeStyles = () => {
|
||||
return {
|
||||
bg: 'bg-gradient-to-br from-slate-50 via-orange-50 to-amber-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900',
|
||||
card: 'bg-white/70 dark:bg-gray-800/70 backdrop-blur-lg',
|
||||
text: 'text-gray-900 dark:text-white',
|
||||
subtext: 'text-gray-600 dark:text-gray-300',
|
||||
accent: 'text-orange-500 dark:text-orange-400'
|
||||
};
|
||||
};
|
||||
|
||||
const getIconColor = () => {
|
||||
switch (type) {
|
||||
case '404': return 'text-orange-500';
|
||||
case '500': return 'text-red-500';
|
||||
case '403': return 'text-yellow-500';
|
||||
case 'network': return 'text-blue-500';
|
||||
case 'timeout': return 'text-purple-500';
|
||||
case 'maintenance': return 'text-gray-500';
|
||||
default: return 'text-orange-500';
|
||||
}
|
||||
const colors = {
|
||||
'404': 'text-orange-500',
|
||||
'500': 'text-red-500',
|
||||
'403': 'text-yellow-500',
|
||||
'network': 'text-blue-500',
|
||||
'timeout': 'text-purple-500',
|
||||
'maintenance': 'text-gray-500',
|
||||
'custom': 'text-gray-500'
|
||||
};
|
||||
return colors[type] || 'text-gray-500';
|
||||
};
|
||||
|
||||
const getCodeColor = () => {
|
||||
switch (type) {
|
||||
case '404': return 'from-orange-400 via-orange-500 to-amber-500';
|
||||
case '500': return 'from-red-400 via-red-500 to-pink-500';
|
||||
case '403': return 'from-yellow-400 via-yellow-500 to-orange-500';
|
||||
case 'network': return 'from-blue-400 via-blue-500 to-cyan-500';
|
||||
case 'timeout': return 'from-purple-400 via-purple-500 to-pink-500';
|
||||
case 'maintenance': return 'from-gray-400 via-gray-500 to-slate-500';
|
||||
default: return 'from-orange-400 via-orange-500 to-amber-500';
|
||||
const colors = {
|
||||
'404': 'from-orange-400 via-orange-500 to-amber-500',
|
||||
'500': 'from-red-400 via-red-500 to-pink-500',
|
||||
'403': 'from-yellow-400 via-yellow-500 to-orange-500',
|
||||
'network': 'from-blue-400 via-blue-500 to-cyan-500',
|
||||
'timeout': 'from-purple-400 via-purple-500 to-pink-500',
|
||||
'maintenance': 'from-gray-400 via-gray-500 to-slate-500',
|
||||
'custom': 'from-gray-400 via-gray-500 to-slate-500'
|
||||
};
|
||||
return colors[type] || 'from-gray-400 via-gray-500 to-slate-500';
|
||||
};
|
||||
|
||||
const getButtonColor = () => {
|
||||
return 'from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600';
|
||||
};
|
||||
|
||||
const handleRetry = async () => {
|
||||
if (onRetry) {
|
||||
setIsRetrying(true);
|
||||
try {
|
||||
await onRetry();
|
||||
messageManager.success('重试成功!', { duration: 3000 });
|
||||
} catch (error) {
|
||||
messageManager.error('重试失败,请稍后重试', { duration: 5000 });
|
||||
} finally {
|
||||
setIsRetrying(false);
|
||||
}
|
||||
} else {
|
||||
// 默认重试逻辑:刷新页面
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyError = async () => {
|
||||
try {
|
||||
const details = generateErrorDetails();
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard) {
|
||||
await navigator.clipboard.writeText(details);
|
||||
setCopySuccess(true);
|
||||
messageManager.success('错误信息已复制到剪贴板', { duration: 2000 });
|
||||
setTimeout(() => setCopySuccess(false), 2000);
|
||||
} else {
|
||||
// 降级方案
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = details;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
setCopySuccess(true);
|
||||
messageManager.success('错误信息已复制到剪贴板', { duration: 2000 });
|
||||
setTimeout(() => setCopySuccess(false), 2000);
|
||||
}
|
||||
} catch (error) {
|
||||
messageManager.error('复制失败,请手动复制', { duration: 3000 });
|
||||
}
|
||||
};
|
||||
|
||||
const handleReportError = () => {
|
||||
messageManager.info('感谢您的反馈,我们会尽快处理', { duration: 3000 });
|
||||
// 这里可以添加实际的错误报告逻辑
|
||||
};
|
||||
|
||||
// 键盘快捷键支持
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'r' && (event.ctrlKey || event.metaKey)) {
|
||||
event.preventDefault();
|
||||
handleRetry();
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
}, [handleRetry]);
|
||||
|
||||
const themeStyles = getThemeStyles();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-orange-50 via-white to-amber-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
|
||||
<motion.div
|
||||
className="text-center px-4 max-w-2xl mx-auto"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, ease: 'easeOut' }}
|
||||
>
|
||||
{/* 错误代码 */}
|
||||
{code && (
|
||||
<motion.div
|
||||
className="mb-8"
|
||||
initial={{ scale: 0.5, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 0.2, duration: 0.6, type: 'spring', stiffness: 200 }}
|
||||
>
|
||||
<h1 className={`text-9xl font-black bg-gradient-to-r ${getCodeColor()} bg-clip-text text-transparent mb-4`}>
|
||||
{code}
|
||||
</h1>
|
||||
<div className={`w-24 h-1 bg-gradient-to-r ${getCodeColor()} mx-auto rounded-full`} />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 图标 */}
|
||||
<div className={`min-h-screen bg-gradient-to-br from-slate-50 via-orange-50 to-amber-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900 ${className}`}>
|
||||
{/* Animated Background - 简化背景动画 */}
|
||||
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||
<motion.div
|
||||
className="mb-8 flex justify-center"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.4, duration: 0.6 }}
|
||||
>
|
||||
<div className={`${getIconColor()}`}>
|
||||
{config.icon || <ExclamationTriangleIcon className="w-16 h-16" />}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 错误信息 */}
|
||||
className="absolute top-1/4 left-1/4 w-96 h-96 bg-gradient-to-br from-orange-400/10 to-amber-400/10 rounded-full blur-3xl"
|
||||
animate={{
|
||||
scale: [1, 1.2, 1],
|
||||
opacity: [0.3, 0.5, 0.3]
|
||||
}}
|
||||
transition={{ duration: 4, repeat: Infinity }}
|
||||
/>
|
||||
<motion.div
|
||||
className="mb-8"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.6, duration: 0.6 }}
|
||||
>
|
||||
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{displayTitle}
|
||||
</h2>
|
||||
<p className="text-xl text-gray-700 dark:text-gray-300 mb-2">
|
||||
{displayMessage}
|
||||
</p>
|
||||
{displayDescription && (
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||
{displayDescription}
|
||||
</p>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<motion.div
|
||||
className="flex flex-col sm:flex-row gap-4 justify-center items-center"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.8, duration: 0.6 }}
|
||||
>
|
||||
{finalActions.primary && (
|
||||
finalActions.primary.href ? (
|
||||
<Link
|
||||
href={finalActions.primary.href}
|
||||
className="inline-flex items-center px-6 py-3 bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white font-semibold rounded-xl transition-all duration-200 shadow-lg hover:shadow-xl transform hover:scale-105"
|
||||
>
|
||||
<HomeIcon className="w-5 h-5 mr-2" />
|
||||
{finalActions.primary.label}
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
onClick={finalActions.primary.onClick}
|
||||
className="inline-flex items-center px-6 py-3 bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white font-semibold rounded-xl transition-all duration-200 shadow-lg hover:shadow-xl transform hover:scale-105"
|
||||
>
|
||||
<HomeIcon className="w-5 h-5 mr-2" />
|
||||
{finalActions.primary.label}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
{finalActions.secondary && (
|
||||
finalActions.secondary.href ? (
|
||||
<Link
|
||||
href={finalActions.secondary.href}
|
||||
className="inline-flex items-center px-6 py-3 border-2 border-orange-500 text-orange-500 hover:bg-orange-500 hover:text-white font-semibold rounded-xl transition-all duration-200"
|
||||
>
|
||||
<ArrowLeftIcon className="w-5 h-5 mr-2" />
|
||||
{finalActions.secondary.label}
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
onClick={finalActions.secondary.onClick}
|
||||
className="inline-flex items-center px-6 py-3 border-2 border-orange-500 text-orange-500 hover:bg-orange-500 hover:text-white font-semibold rounded-xl transition-all duration-200"
|
||||
>
|
||||
<ArrowLeftIcon className="w-5 h-5 mr-2" />
|
||||
{finalActions.secondary.label}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* 联系信息 */}
|
||||
{showContact && (
|
||||
<motion.div
|
||||
className="mt-8 text-sm text-gray-500 dark:text-gray-400"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 1, duration: 0.6 }}
|
||||
>
|
||||
<p>如果问题持续存在,请
|
||||
<Link href="/contact" className="text-orange-500 hover:text-orange-600 underline mx-1">
|
||||
联系我们
|
||||
</Link>
|
||||
的支持团队
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* 背景装饰 */}
|
||||
<div className="fixed inset-0 -z-10 overflow-hidden">
|
||||
<div className="absolute top-1/4 left-1/4 w-64 h-64 bg-orange-200/20 dark:bg-orange-900/20 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-amber-200/20 dark:bg-amber-900/20 rounded-full blur-3xl" />
|
||||
className="absolute bottom-1/4 right-1/4 w-80 h-80 bg-gradient-to-tr from-pink-400/10 to-orange-400/10 rounded-full blur-3xl"
|
||||
animate={{
|
||||
scale: [1.2, 1, 1.2],
|
||||
opacity: [0.5, 0.3, 0.5]
|
||||
}}
|
||||
transition={{ duration: 5, repeat: Infinity }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main Content - 考虑navbar高度的居中布局 */}
|
||||
<motion.div
|
||||
className="relative z-10 min-h-[calc(100vh-var(--navbar-height,4rem))] flex items-center justify-center px-4 sm:px-6 lg:px-8"
|
||||
style={{ paddingTop: 'calc(var(--navbar-height, 4rem) + 2rem)' }}
|
||||
>
|
||||
<div className="max-w-4xl mx-auto w-full text-center">
|
||||
|
||||
{/* Error Code - 更突出的错误代码 */}
|
||||
{code && (
|
||||
<motion.div
|
||||
initial={{ scale: 0.5, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.8, type: 'spring', stiffness: 100 }}
|
||||
className="mb-12"
|
||||
>
|
||||
<h1 className={`text-9xl md:text-[12rem] font-black bg-gradient-to-r ${getCodeColor()} bg-clip-text text-transparent mb-2`}>
|
||||
{code}
|
||||
</h1>
|
||||
<motion.div
|
||||
className={`w-24 h-1 bg-gradient-to-r ${getCodeColor()} mx-auto rounded-full`}
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: 96 }}
|
||||
transition={{ delay: 0.5, duration: 0.8 }}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Main Error Message - 简洁有力的错误信息 */}
|
||||
<motion.div
|
||||
className="mb-16 space-y-6"
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3, duration: 0.8 }}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-4xl md:text-6xl font-bold text-gray-900 dark:text-white leading-tight">
|
||||
{displayTitle}
|
||||
</h2>
|
||||
<p className="text-xl md:text-2xl text-gray-600 dark:text-gray-300 leading-relaxed max-w-2xl mx-auto">
|
||||
{displayMessage}
|
||||
</p>
|
||||
{displayDescription && (
|
||||
<p className="text-lg text-gray-500 dark:text-gray-400 leading-relaxed max-w-xl mx-auto">
|
||||
{displayDescription}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Icon - 更简洁的图标展示 */}
|
||||
<motion.div
|
||||
className="flex justify-center"
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.6, duration: 0.6 }}
|
||||
>
|
||||
<div className={`${getIconColor()} opacity-80`}>
|
||||
{config.icon || <QuestionMarkCircleIcon className="w-24 h-24" />}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Action Buttons - 更简洁的按钮布局 */}
|
||||
<motion.div
|
||||
className="mb-12"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.8, duration: 0.6 }}
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center mb-6">
|
||||
{finalActions.primary && (
|
||||
<ActionButton action={finalActions.primary} colorClass={getButtonColor()} primary />
|
||||
)}
|
||||
|
||||
{finalActions.secondary && (
|
||||
<ActionButton action={finalActions.secondary} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showRetry && (
|
||||
<motion.button
|
||||
onClick={handleRetry}
|
||||
disabled={isRetrying}
|
||||
className="inline-flex items-center px-8 py-4 bg-gradient-to-r from-orange-500 to-amber-500 disabled:opacity-50 disabled:cursor-not-allowed text-white font-semibold rounded-2xl transition-all duration-200 shadow-lg hover:shadow-xl transform hover:scale-105"
|
||||
whileHover={{ scale: isRetrying ? 1 : 1.05 }}
|
||||
whileTap={{ scale: isRetrying ? 1 : 0.95 }}
|
||||
>
|
||||
<ArrowPathIcon className={`w-5 h-5 mr-2 ${isRetrying ? 'animate-spin' : ''}`} />
|
||||
{isRetrying ? '重试中...' : '重新加载'}
|
||||
</motion.button>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Suggestions - 更简洁的建议展示 */}
|
||||
{config.suggestions && config.suggestions.length > 0 && (
|
||||
<motion.div
|
||||
className="mb-12"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 1, duration: 0.6 }}
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-gray-700 dark:text-gray-300 mb-4">
|
||||
您可以尝试:
|
||||
</h3>
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
{config.suggestions.map((suggestion, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
className="flex items-center px-4 py-2 bg-white/50 dark:bg-gray-800/50 rounded-full text-sm text-gray-600 dark:text-gray-400 border border-gray-200 dark:border-gray-700"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 1.2 + index * 0.1, duration: 0.4 }}
|
||||
>
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${getIconColor()} mr-2`} />
|
||||
{suggestion}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Error Details - 更简洁的错误详情 */}
|
||||
{showCopyError && (
|
||||
<motion.div
|
||||
className="mb-12"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 1.4, duration: 0.6 }}
|
||||
>
|
||||
<div className="flex justify-center gap-3 mb-4">
|
||||
<button
|
||||
onClick={handleCopyError}
|
||||
className="inline-flex items-center px-4 py-2 bg-white/50 dark:bg-gray-800/50 hover:bg-white/70 dark:hover:bg-gray-800/70 text-gray-700 dark:text-gray-300 text-sm font-medium rounded-full transition-all duration-200 border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<ClipboardDocumentIcon className="w-4 h-4 mr-2" />
|
||||
{copySuccess ? '已复制!' : '复制错误信息'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
className="inline-flex items-center px-4 py-2 bg-white/50 dark:bg-gray-800/50 hover:bg-white/70 dark:hover:bg-gray-800/70 text-gray-700 dark:text-gray-300 text-sm font-medium rounded-full transition-all duration-200 border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
{showDetails ? '隐藏详情' : '显示详情'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{showDetails && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="max-w-2xl mx-auto p-4 bg-white/30 dark:bg-gray-800/30 rounded-2xl text-left border border-white/50 dark:border-gray-700/50 backdrop-blur-sm"
|
||||
>
|
||||
<h4 className="font-semibold text-gray-900 dark:text-white mb-2 text-sm">错误详情:</h4>
|
||||
<pre className="text-xs text-gray-600 dark:text-gray-400 overflow-auto max-h-24">
|
||||
{generateErrorDetails()}
|
||||
</pre>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Contact Info - 更简洁的联系信息 */}
|
||||
{showContact && (
|
||||
<motion.div
|
||||
className="text-center pb-8"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 1.6, duration: 0.6 }}
|
||||
>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
问题仍未解决?
|
||||
<Link
|
||||
href="/contact"
|
||||
className="text-orange-500 hover:text-orange-600 underline mx-1 font-medium"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
messageManager.info('联系我们页面正在开发中', { duration: 3000 });
|
||||
}}
|
||||
>
|
||||
联系我们
|
||||
</Link>
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -277,3 +556,127 @@ export function TimeoutErrorPage() {
|
||||
export function MaintenancePage() {
|
||||
return <ErrorPage type="maintenance" />;
|
||||
}
|
||||
|
||||
// 现代化的错误页面
|
||||
export function ModernNotFoundPage() {
|
||||
return <ErrorPage type="404" code={404} />;
|
||||
}
|
||||
|
||||
export function ModernServerErrorPage() {
|
||||
return <ErrorPage type="500" code={500} />;
|
||||
}
|
||||
|
||||
// 使用示例和最佳实践
|
||||
/*
|
||||
|
||||
增强后的ErrorPage组件提供了以下改进:
|
||||
|
||||
1. **统一的消息提示系统**
|
||||
- 使用MessageNotification组件替代alert
|
||||
- 支持成功、错误、警告、信息、加载等多种消息类型
|
||||
- 更好的用户体验和视觉效果
|
||||
|
||||
2. **多种主题风格**
|
||||
- Minecraft风格:适合游戏相关网站
|
||||
- Modern风格:现代化简洁设计
|
||||
- Minimal风格:极简主义设计
|
||||
|
||||
3. **增强的功能**
|
||||
- 重试功能,支持自定义重试逻辑
|
||||
- 错误信息复制功能
|
||||
- 错误详情显示/隐藏
|
||||
- 键盘快捷键支持 (Ctrl+R/⌘+R 重试)
|
||||
- 进度条显示(可选)
|
||||
- 自定义操作按钮
|
||||
|
||||
4. **改进的用户体验**
|
||||
- 针对每种错误类型提供具体建议
|
||||
- 动态颜色主题匹配错误类型
|
||||
- 平滑的动画过渡效果
|
||||
- 响应式设计,适配移动端
|
||||
- Minecraft风格的游戏化提示
|
||||
|
||||
5. **更好的错误处理**
|
||||
- 详细的错误信息生成
|
||||
- 错误报告功能
|
||||
- 降级处理(如复制功能)
|
||||
- 支持自定义错误详情
|
||||
|
||||
使用示例:
|
||||
|
||||
```tsx
|
||||
// 基础使用 - Minecraft风格404页面
|
||||
<ErrorPage type="404" />
|
||||
|
||||
// 现代风格500错误
|
||||
<ErrorPage type="500" code={500} theme="modern" />
|
||||
|
||||
// 自定义错误信息
|
||||
<ErrorPage
|
||||
type="500"
|
||||
code={500}
|
||||
title="数据库连接失败"
|
||||
message="无法连接到数据库服务器"
|
||||
description="请检查数据库配置或联系系统管理员"
|
||||
theme="modern"
|
||||
/>
|
||||
|
||||
// 自定义操作按钮
|
||||
<ErrorPage
|
||||
type="network"
|
||||
actions={{
|
||||
primary: {
|
||||
label: '重新连接',
|
||||
onClick: () => reconnect()
|
||||
},
|
||||
secondary: {
|
||||
label: '离线模式',
|
||||
href: '/offline'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
// 启用重试功能
|
||||
<ErrorPage
|
||||
type="timeout"
|
||||
showRetry={true}
|
||||
onRetry={async () => {
|
||||
// 自定义重试逻辑
|
||||
await fetchData();
|
||||
}}
|
||||
/>
|
||||
|
||||
// 显示错误详情
|
||||
<ErrorPage
|
||||
type="500"
|
||||
showCopyError={true}
|
||||
errorDetails={JSON.stringify(errorDetails)}
|
||||
theme="minimal"
|
||||
/>
|
||||
|
||||
```
|
||||
|
||||
预设组件使用:
|
||||
|
||||
```tsx
|
||||
// 在页面中使用预设的错误组件
|
||||
import { NotFoundPage, ServerErrorPage, ModernNotFoundPage } from '@/components/ErrorPage';
|
||||
|
||||
// Minecraft风格404页面
|
||||
export default function Custom404() {
|
||||
return <NotFoundPage />;
|
||||
}
|
||||
|
||||
// 现代化404页面
|
||||
export default function Modern404() {
|
||||
return <ModernNotFoundPage />;
|
||||
}
|
||||
|
||||
// 500页面
|
||||
export default function Custom500() {
|
||||
return <ServerErrorPage />;
|
||||
}
|
||||
```
|
||||
|
||||
*/
|
||||
|
||||
|
||||
446
src/components/LoadingStates.tsx
Normal file
446
src/components/LoadingStates.tsx
Normal file
@@ -0,0 +1,446 @@
|
||||
'use client';
|
||||
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||
color?: 'orange' | 'blue' | 'green' | 'red' | 'purple' | 'gray';
|
||||
speed?: 'slow' | 'normal' | 'fast';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LoadingSpinner({
|
||||
size = 'md',
|
||||
color = 'orange',
|
||||
speed = 'normal',
|
||||
className = ''
|
||||
}: LoadingSpinnerProps) {
|
||||
const sizes = {
|
||||
sm: 'w-4 h-4',
|
||||
md: 'w-6 h-6',
|
||||
lg: 'w-8 h-8',
|
||||
xl: 'w-12 h-12'
|
||||
};
|
||||
|
||||
const colors = {
|
||||
orange: 'border-orange-500',
|
||||
blue: 'border-blue-500',
|
||||
green: 'border-green-500',
|
||||
red: 'border-red-500',
|
||||
purple: 'border-purple-500',
|
||||
gray: 'border-gray-500'
|
||||
};
|
||||
|
||||
const speeds = {
|
||||
slow: 'duration-2000',
|
||||
normal: 'duration-1000',
|
||||
fast: 'duration-500'
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={`${sizes[size]} ${colors[color]} border-2 border-t-transparent rounded-full animate-spin ${className}`}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface LoadingDotsProps {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
color?: 'orange' | 'blue' | 'green' | 'red' | 'purple' | 'gray';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LoadingDots({
|
||||
size = 'md',
|
||||
color = 'orange',
|
||||
className = ''
|
||||
}: LoadingDotsProps) {
|
||||
const sizes = {
|
||||
sm: 'w-2 h-2',
|
||||
md: 'w-3 h-3',
|
||||
lg: 'w-4 h-4'
|
||||
};
|
||||
|
||||
const colors = {
|
||||
orange: 'bg-orange-500',
|
||||
blue: 'bg-blue-500',
|
||||
green: 'bg-green-500',
|
||||
red: 'bg-red-500',
|
||||
purple: 'bg-purple-500',
|
||||
gray: 'bg-gray-500'
|
||||
};
|
||||
|
||||
const dotVariants = {
|
||||
initial: { y: 0 },
|
||||
animate: {
|
||||
y: [-8, 0, -8],
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex space-x-1 ${className}`}>
|
||||
{[0, 1, 2].map((index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
className={`${sizes[size]} ${colors[color]} rounded-full`}
|
||||
variants={dotVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
transition={{ delay: index * 0.1 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SkeletonProps {
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||
animated?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Skeleton({
|
||||
width = '100%',
|
||||
height = '1rem',
|
||||
rounded = 'md',
|
||||
animated = true,
|
||||
className = ''
|
||||
}: SkeletonProps) {
|
||||
const roundedClasses = {
|
||||
none: 'rounded-none',
|
||||
sm: 'rounded-sm',
|
||||
md: 'rounded-md',
|
||||
lg: 'rounded-lg',
|
||||
xl: 'rounded-xl',
|
||||
full: 'rounded-full'
|
||||
};
|
||||
|
||||
const shimmerVariants = {
|
||||
initial: { x: '-100%' },
|
||||
animate: {
|
||||
x: '100%',
|
||||
transition: {
|
||||
duration: 1.5,
|
||||
repeat: Infinity,
|
||||
ease: "linear"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={`bg-gray-200 dark:bg-gray-700 ${roundedClasses[rounded]} relative overflow-hidden ${className}`}
|
||||
style={{ width, height }}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{animated && (
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 dark:via-white/10 to-transparent"
|
||||
variants={shimmerVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SkeletonCardProps {
|
||||
lines?: number;
|
||||
image?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SkeletonCard({ lines = 3, image = true, className = '' }: SkeletonCardProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className={`bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 space-y-3 ${className}`}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{image && (
|
||||
<Skeleton width="100%" height="12rem" rounded="md" />
|
||||
)}
|
||||
|
||||
{Array.from({ length: lines }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={index}
|
||||
width={index === lines - 1 ? '60%' : '100%'}
|
||||
height="1rem"
|
||||
rounded="sm"
|
||||
animated={true}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LoadingProgressBarProps {
|
||||
progress?: number;
|
||||
color?: 'orange' | 'blue' | 'green' | 'red' | 'purple';
|
||||
animated?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LoadingProgressBar({
|
||||
progress = 0,
|
||||
color = 'orange',
|
||||
animated = true,
|
||||
className = ''
|
||||
}: LoadingProgressBarProps) {
|
||||
const colors = {
|
||||
orange: 'bg-gradient-to-r from-orange-400 to-amber-500',
|
||||
blue: 'bg-gradient-to-r from-blue-400 to-cyan-500',
|
||||
green: 'bg-gradient-to-r from-green-400 to-emerald-500',
|
||||
red: 'bg-gradient-to-r from-red-400 to-pink-500',
|
||||
purple: 'bg-gradient-to-r from-purple-400 to-violet-500'
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={`w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden ${className}`}
|
||||
initial={{ opacity: 0, scaleX: 0 }}
|
||||
animate={{ opacity: 1, scaleX: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<motion.div
|
||||
className={`h-full ${colors[color]} rounded-full`}
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${Math.min(Math.max(progress, 0), 100)}%` }}
|
||||
transition={{
|
||||
duration: animated ? 0.5 : 0,
|
||||
ease: "easeOut"
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PulseLoaderProps {
|
||||
color?: 'orange' | 'blue' | 'green' | 'red' | 'purple' | 'gray';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PulseLoader({
|
||||
color = 'orange',
|
||||
size = 'md',
|
||||
className = ''
|
||||
}: PulseLoaderProps) {
|
||||
const colors = {
|
||||
orange: 'bg-orange-500',
|
||||
blue: 'bg-blue-500',
|
||||
green: 'bg-green-500',
|
||||
red: 'bg-red-500',
|
||||
purple: 'bg-purple-500',
|
||||
gray: 'bg-gray-500'
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: 'w-8 h-8',
|
||||
md: 'w-12 h-12',
|
||||
lg: 'w-16 h-16'
|
||||
};
|
||||
|
||||
const pulseVariants = {
|
||||
initial: { scale: 0.8, opacity: 0.6 },
|
||||
animate: {
|
||||
scale: [0.8, 1.2, 0.8],
|
||||
opacity: [0.6, 1, 0.6],
|
||||
transition: {
|
||||
duration: 1.5,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={`${sizes[size]} ${colors[color]} rounded-full ${className}`}
|
||||
variants={pulseVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface LoadingContainerProps {
|
||||
isLoading: boolean;
|
||||
children: ReactNode;
|
||||
loadingType?: 'spinner' | 'dots' | 'pulse' | 'skeleton';
|
||||
loadingText?: string;
|
||||
skeletonCount?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LoadingContainer({
|
||||
isLoading,
|
||||
children,
|
||||
loadingType = 'spinner',
|
||||
loadingText = '加载中...',
|
||||
skeletonCount = 3,
|
||||
className = ''
|
||||
}: LoadingContainerProps) {
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
{isLoading ? (
|
||||
<motion.div
|
||||
key="loading"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className={`flex flex-col items-center justify-center p-8 ${className}`}
|
||||
>
|
||||
{loadingType === 'spinner' && <LoadingSpinner size="lg" />}
|
||||
{loadingType === 'dots' && <LoadingDots size="lg" />}
|
||||
{loadingType === 'pulse' && <PulseLoader size="md" />}
|
||||
{loadingType === 'skeleton' && (
|
||||
<div className="w-full space-y-4">
|
||||
{Array.from({ length: skeletonCount }).map((_, index) => (
|
||||
<SkeletonCard key={index} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loadingText && loadingType !== 'skeleton' && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="mt-4 text-gray-600 dark:text-gray-400 font-medium"
|
||||
>
|
||||
{loadingText}
|
||||
</motion.p>
|
||||
)}
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="content"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
interface InfiniteScrollLoaderProps {
|
||||
isLoading: boolean;
|
||||
hasMore: boolean;
|
||||
onLoadMore: () => void;
|
||||
threshold?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function InfiniteScrollLoader({
|
||||
isLoading,
|
||||
hasMore,
|
||||
onLoadMore,
|
||||
threshold = 0.8,
|
||||
className = ''
|
||||
}: InfiniteScrollLoaderProps) {
|
||||
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
||||
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
|
||||
|
||||
if (scrollPercentage > threshold && !isLoading && hasMore) {
|
||||
onLoadMore();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className} onScroll={handleScroll}>
|
||||
{isLoading && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex justify-center py-4"
|
||||
>
|
||||
<LoadingSpinner size="md" />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{!hasMore && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="text-center py-4 text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
没有更多内容了
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 骨架屏组件集合
|
||||
export function SkeletonSkinCard() {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-4 space-y-4">
|
||||
<Skeleton width="100%" height="12rem" rounded="lg" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton width="80%" height="1.5rem" rounded="sm" />
|
||||
<Skeleton width="60%" height="1rem" rounded="sm" />
|
||||
<Skeleton width="40%" height="1rem" rounded="sm" />
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<Skeleton width="30%" height="2rem" rounded="md" />
|
||||
<Skeleton width="20%" height="2rem" rounded="md" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonProfileCard() {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 space-y-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Skeleton width="4rem" height="4rem" rounded="full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton width="60%" height="1.5rem" rounded="sm" />
|
||||
<Skeleton width="40%" height="1rem" rounded="sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton width="100%" height="1rem" rounded="sm" />
|
||||
<Skeleton width="80%" height="1rem" rounded="sm" />
|
||||
<Skeleton width="60%" height="1rem" rounded="sm" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonNavbar() {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 shadow-md p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton width="8rem" height="2rem" rounded="md" />
|
||||
<div className="flex space-x-4">
|
||||
<Skeleton width="4rem" height="2rem" rounded="md" />
|
||||
<Skeleton width="4rem" height="2rem" rounded="md" />
|
||||
<Skeleton width="4rem" height="2rem" rounded="md" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,11 +6,12 @@ export function MainContent({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const isAuthPage = pathname === '/auth';
|
||||
const isHomePage = pathname === '/';
|
||||
const isErrorPage = pathname === '/404' || pathname === '/500' || pathname === '/403' || pathname === '/error';
|
||||
|
||||
return (
|
||||
<main className={`
|
||||
min-h-screen bg-gradient-to-br from-slate-50 via-orange-50 to-amber-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900
|
||||
${isAuthPage || isHomePage ? '' : 'pt-16'}
|
||||
${isAuthPage || isHomePage || isErrorPage ? '' : 'pt-16'}
|
||||
`}>
|
||||
{children}
|
||||
</main>
|
||||
|
||||
381
src/components/MessageNotification.tsx
Normal file
381
src/components/MessageNotification.tsx
Normal file
@@ -0,0 +1,381 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
XMarkIcon,
|
||||
ExclamationTriangleIcon,
|
||||
CheckCircleIcon,
|
||||
InformationCircleIcon,
|
||||
XCircleIcon,
|
||||
BellIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
export type MessageType = 'success' | 'error' | 'warning' | 'info' | 'loading';
|
||||
|
||||
export interface MessageNotificationProps {
|
||||
message: string;
|
||||
type?: MessageType;
|
||||
duration?: number;
|
||||
onClose?: () => void;
|
||||
title?: string;
|
||||
position?: 'top-left' | 'top-right' | 'top-center' | 'bottom-left' | 'bottom-right' | 'bottom-center';
|
||||
showProgress?: boolean;
|
||||
closable?: boolean;
|
||||
action?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
interface Message extends MessageNotificationProps {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export function MessageNotification({
|
||||
message,
|
||||
type = 'info',
|
||||
duration = 3000,
|
||||
onClose,
|
||||
title,
|
||||
position = 'top-right',
|
||||
showProgress = true,
|
||||
closable = true,
|
||||
action
|
||||
}: MessageNotificationProps) {
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
const [remainingTime, setRemainingTime] = useState(duration);
|
||||
|
||||
useEffect(() => {
|
||||
if (duration > 0 && type !== 'loading') {
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
onClose?.();
|
||||
}, duration);
|
||||
|
||||
// 进度条更新
|
||||
const progressTimer = setInterval(() => {
|
||||
setRemainingTime(prev => {
|
||||
const newTime = prev - 100;
|
||||
if (newTime <= 0) {
|
||||
clearInterval(progressTimer);
|
||||
}
|
||||
return newTime;
|
||||
});
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
clearInterval(progressTimer);
|
||||
};
|
||||
}
|
||||
}, [duration, onClose, type]);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsVisible(false);
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const getIcon = () => {
|
||||
const iconClass = "w-5 h-5";
|
||||
switch (type) {
|
||||
case 'error':
|
||||
return <XCircleIcon className={iconClass} />;
|
||||
case 'warning':
|
||||
return <ExclamationTriangleIcon className={iconClass} />;
|
||||
case 'success':
|
||||
return <CheckCircleIcon className={iconClass} />;
|
||||
case 'loading':
|
||||
return (
|
||||
<div className="animate-spin">
|
||||
<svg className={iconClass} viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
case 'info':
|
||||
default:
|
||||
return <InformationCircleIcon className={iconClass} />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStyles = () => {
|
||||
switch (type) {
|
||||
case 'error':
|
||||
return {
|
||||
bg: 'bg-red-50 dark:bg-red-900/20',
|
||||
border: 'border-red-200 dark:border-red-800',
|
||||
text: 'text-red-800 dark:text-red-200',
|
||||
icon: 'text-red-500',
|
||||
close: 'text-red-400 hover:text-red-600 dark:text-red-300 dark:hover:text-red-100',
|
||||
progress: 'bg-red-500',
|
||||
action: 'bg-red-500 hover:bg-red-600 text-white'
|
||||
};
|
||||
case 'warning':
|
||||
return {
|
||||
bg: 'bg-yellow-50 dark:bg-yellow-900/20',
|
||||
border: 'border-yellow-200 dark:border-yellow-800',
|
||||
text: 'text-yellow-800 dark:text-yellow-200',
|
||||
icon: 'text-yellow-500',
|
||||
close: 'text-yellow-400 hover:text-yellow-600 dark:text-yellow-300 dark:hover:text-yellow-100',
|
||||
progress: 'bg-yellow-500',
|
||||
action: 'bg-yellow-500 hover:bg-yellow-600 text-white'
|
||||
};
|
||||
case 'success':
|
||||
return {
|
||||
bg: 'bg-green-50 dark:bg-green-900/20',
|
||||
border: 'border-green-200 dark:border-green-800',
|
||||
text: 'text-green-800 dark:text-green-200',
|
||||
icon: 'text-green-500',
|
||||
close: 'text-green-400 hover:text-green-600 dark:text-green-300 dark:hover:text-green-100',
|
||||
progress: 'bg-green-500',
|
||||
action: 'bg-green-500 hover:bg-green-600 text-white'
|
||||
};
|
||||
case 'loading':
|
||||
return {
|
||||
bg: 'bg-blue-50 dark:bg-blue-900/20',
|
||||
border: 'border-blue-200 dark:border-blue-800',
|
||||
text: 'text-blue-800 dark:text-blue-200',
|
||||
icon: 'text-blue-500',
|
||||
close: 'text-blue-400 hover:text-blue-600 dark:text-blue-300 dark:hover:text-blue-100',
|
||||
progress: 'bg-blue-500',
|
||||
action: 'bg-blue-500 hover:bg-blue-600 text-white'
|
||||
};
|
||||
case 'info':
|
||||
default:
|
||||
return {
|
||||
bg: 'bg-blue-50 dark:bg-blue-900/20',
|
||||
border: 'border-blue-200 dark:border-blue-800',
|
||||
text: 'text-blue-800 dark:text-blue-200',
|
||||
icon: 'text-blue-500',
|
||||
close: 'text-blue-400 hover:text-blue-600 dark:text-blue-300 dark:hover:text-blue-100',
|
||||
progress: 'bg-blue-500',
|
||||
action: 'bg-blue-500 hover:bg-blue-600 text-white'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const getPositionStyles = () => {
|
||||
switch (position) {
|
||||
case 'top-left':
|
||||
return 'top-4 left-4';
|
||||
case 'top-center':
|
||||
return 'top-4 left-1/2 -translate-x-1/2';
|
||||
case 'top-right':
|
||||
return 'top-4 right-4';
|
||||
case 'bottom-left':
|
||||
return 'bottom-4 left-4';
|
||||
case 'bottom-center':
|
||||
return 'bottom-4 left-1/2 -translate-x-1/2';
|
||||
case 'bottom-right':
|
||||
return 'bottom-4 right-4';
|
||||
default:
|
||||
return 'top-4 right-4';
|
||||
}
|
||||
};
|
||||
|
||||
const styles = getStyles();
|
||||
const positionStyles = getPositionStyles();
|
||||
const progressPercentage = duration > 0 ? (remainingTime / duration) * 100 : 0;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isVisible && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20, scale: 0.9 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -20, scale: 0.9 }}
|
||||
transition={{ duration: 0.3, ease: 'easeOut' }}
|
||||
className={`fixed z-[9999] max-w-sm w-full ${positionStyles}`}
|
||||
>
|
||||
<div className={`${styles.bg} ${styles.border} border rounded-xl shadow-lg backdrop-blur-sm overflow-hidden`}>
|
||||
<div className="flex items-start p-4">
|
||||
<div className={`flex-shrink-0 ${styles.icon} mr-3 mt-0.5`}>
|
||||
{getIcon()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
{title && (
|
||||
<h4 className={`text-sm font-semibold ${styles.text} mb-1`}>
|
||||
{title}
|
||||
</h4>
|
||||
)}
|
||||
<p className={`text-sm ${styles.text}`}>
|
||||
{message}
|
||||
</p>
|
||||
{action && (
|
||||
<button
|
||||
onClick={action.onClick}
|
||||
className={`mt-2 px-3 py-1 text-xs font-medium rounded-md transition-colors ${styles.action}`}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{closable && (
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className={`flex-shrink-0 ml-3 ${styles.close} transition-colors`}
|
||||
>
|
||||
<XMarkIcon className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showProgress && duration > 0 && type !== 'loading' && (
|
||||
<div className="h-1 bg-gray-200 dark:bg-gray-700 overflow-hidden">
|
||||
<motion.div
|
||||
className={`h-full ${styles.progress}`}
|
||||
initial={{ width: '100%' }}
|
||||
animate={{ width: `${progressPercentage}%` }}
|
||||
transition={{ duration: 0.1, ease: 'linear' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
// 全局消息管理器
|
||||
class MessageManager {
|
||||
private static instance: MessageManager;
|
||||
private listeners: Array<(message: Message) => void> = [];
|
||||
|
||||
static getInstance(): MessageManager {
|
||||
if (!MessageManager.instance) {
|
||||
MessageManager.instance = new MessageManager();
|
||||
}
|
||||
return MessageManager.instance;
|
||||
}
|
||||
|
||||
// 基础消息方法
|
||||
show(message: string, options?: Omit<MessageNotificationProps, 'message'>) {
|
||||
return this.addMessage(message, 'info', options);
|
||||
}
|
||||
|
||||
success(message: string, options?: Omit<MessageNotificationProps, 'message'>) {
|
||||
return this.addMessage(message, 'success', options);
|
||||
}
|
||||
|
||||
error(message: string, options?: Omit<MessageNotificationProps, 'message'>) {
|
||||
return this.addMessage(message, 'error', options);
|
||||
}
|
||||
|
||||
warning(message: string, options?: Omit<MessageNotificationProps, 'message'>) {
|
||||
return this.addMessage(message, 'warning', options);
|
||||
}
|
||||
|
||||
info(message: string, options?: Omit<MessageNotificationProps, 'message'>) {
|
||||
return this.addMessage(message, 'info', options);
|
||||
}
|
||||
|
||||
loading(message: string, options?: Omit<MessageNotificationProps, 'message'>) {
|
||||
return this.addMessage(message, 'loading', { ...options, duration: 0 });
|
||||
}
|
||||
|
||||
// 隐藏加载消息
|
||||
hideLoading(id?: string) {
|
||||
if (id) {
|
||||
this.listeners.forEach(listener => listener({ id, message: '', type: 'loading', createdAt: Date.now() } as Message));
|
||||
}
|
||||
}
|
||||
|
||||
private addMessage(message: string, type: MessageType, options?: Omit<MessageNotificationProps, 'message'>) {
|
||||
const id = Math.random().toString(36).substr(2, 9);
|
||||
const notification: Message = {
|
||||
id,
|
||||
message,
|
||||
type,
|
||||
createdAt: Date.now(),
|
||||
...options
|
||||
};
|
||||
|
||||
this.listeners.forEach(listener => listener(notification));
|
||||
return id;
|
||||
}
|
||||
|
||||
subscribe(listener: (message: Message) => void) {
|
||||
this.listeners.push(listener);
|
||||
return () => {
|
||||
this.listeners = this.listeners.filter(l => l !== listener);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const messageManager = MessageManager.getInstance();
|
||||
|
||||
// 消息提示容器组件
|
||||
export function MessageNotificationContainer() {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = messageManager.subscribe((message) => {
|
||||
if (message.type === 'loading' && message.message === '') {
|
||||
// 隐藏加载消息
|
||||
setMessages(prev => prev.filter(m => m.id !== message.id));
|
||||
} else {
|
||||
setMessages(prev => [...prev, message]);
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
const removeMessage = (id: string) => {
|
||||
setMessages(prev => prev.filter(m => m.id !== id));
|
||||
};
|
||||
|
||||
// 按位置分组消息
|
||||
const messagesByPosition = messages.reduce((acc, message) => {
|
||||
const position = message.position || 'top-right';
|
||||
if (!acc[position]) {
|
||||
acc[position] = [];
|
||||
}
|
||||
acc[position].push(message);
|
||||
return acc;
|
||||
}, {} as Record<string, Message[]>);
|
||||
|
||||
return (
|
||||
<>
|
||||
{Object.entries(messagesByPosition).map(([position, positionMessages]) => (
|
||||
<div key={position} className="fixed inset-0 pointer-events-none z-[9999]">
|
||||
<div className={`absolute ${getPositionContainerStyles(position)} space-y-2 pointer-events-auto`}>
|
||||
{positionMessages.map((message) => (
|
||||
<MessageNotification
|
||||
key={message.id}
|
||||
{...message}
|
||||
onClose={() => removeMessage(message.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getPositionContainerStyles(position: string) {
|
||||
switch (position) {
|
||||
case 'top-left':
|
||||
return 'top-4 left-4';
|
||||
case 'top-center':
|
||||
return 'top-4 left-1/2 -translate-x-1/2';
|
||||
case 'top-right':
|
||||
return 'top-4 right-4';
|
||||
case 'bottom-left':
|
||||
return 'bottom-4 left-4';
|
||||
case 'bottom-center':
|
||||
return 'bottom-4 left-1/2 -translate-x-1/2';
|
||||
case 'bottom-right':
|
||||
return 'bottom-4 right-4';
|
||||
default:
|
||||
return 'top-4 right-4';
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容层:保持与ErrorNotification的兼容性
|
||||
export { errorManager } from './ErrorNotification';
|
||||
export type { ErrorType } from './ErrorNotification';
|
||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { Bars3Icon, XMarkIcon, UserCircleIcon } from '@heroicons/react/24/outline';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { motion, AnimatePresence, useScroll, useTransform, useSpring } from 'framer-motion';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
export default function Navbar() {
|
||||
@@ -13,10 +13,18 @@ export default function Navbar() {
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [showScrollTop, setShowScrollTop] = useState(false);
|
||||
const [navbarHeight, setNavbarHeight] = useState(0);
|
||||
const [scrollProgress, setScrollProgress] = useState(0);
|
||||
const navbarRef = useRef<HTMLElement>(null);
|
||||
const { user, isAuthenticated, logout } = useAuth();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { scrollY } = useScroll();
|
||||
|
||||
// 弹簧动画效果
|
||||
const springConfig = { stiffness: 300, damping: 30 };
|
||||
const navbarY = useSpring(useTransform(scrollY, [0, 100], [0, -100]), springConfig);
|
||||
const navbarOpacity = useSpring(useTransform(scrollY, [0, 50], [1, 0.95]), springConfig);
|
||||
const scrollProgressSpring = useSpring(scrollProgress, springConfig);
|
||||
|
||||
// 在auth页面隐藏navbar
|
||||
const isAuthPage = pathname === '/auth';
|
||||
@@ -36,6 +44,19 @@ export default function Navbar() {
|
||||
return () => window.removeEventListener('resize', updateHeight);
|
||||
}, []);
|
||||
|
||||
// 滚动进度计算
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
const scrollTop = window.scrollY;
|
||||
const docHeight = document.documentElement.scrollHeight - window.innerHeight;
|
||||
const progress = docHeight > 0 ? (scrollTop / docHeight) * 100 : 0;
|
||||
setScrollProgress(progress);
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let lastScrollY = 0;
|
||||
let ticking = false;
|
||||
@@ -94,294 +115,377 @@ export default function Navbar() {
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.nav
|
||||
ref={navbarRef}
|
||||
initial={{ y: 0 }}
|
||||
animate={{ y: isHidden ? -100 : 0 }}
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
className="fixed top-0 left-0 right-0 z-50 transition-all duration-300 bg-white/80 dark:bg-gray-800/80 backdrop-blur-lg border-b border-gray-200/50 dark:border-gray-700/50"
|
||||
style={{ willChange: 'transform' }}
|
||||
>
|
||||
<div className={`
|
||||
max-w-7xl mx-auto px-4 sm:px-6 lg:px-8
|
||||
${isScrolled ? 'py-3' : 'py-4'}
|
||||
transition-all duration-300
|
||||
`}>
|
||||
<div className="flex justify-between items-center">
|
||||
{/* Logo */}
|
||||
<motion.div
|
||||
className="flex items-center"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Link href="/" className="flex items-center space-x-3 group">
|
||||
<motion.div
|
||||
className="w-10 h-10 bg-gradient-to-br from-orange-400 via-orange-500 to-orange-600 rounded-xl flex items-center justify-center shadow-lg"
|
||||
whileHover={{ rotate: 5, scale: 1.05 }}
|
||||
transition={{ type: 'spring', stiffness: 300 }}
|
||||
>
|
||||
<span className="text-white font-bold text-lg">C</span>
|
||||
</motion.div>
|
||||
<motion.span
|
||||
className="text-2xl font-black bg-gradient-to-r from-orange-400 to-orange-600 bg-clip-text text-transparent"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
>
|
||||
CarrotSkin
|
||||
</motion.span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
const navItems = [
|
||||
{ href: '/', label: '首页', icon: null },
|
||||
{ href: '/skins', label: '皮肤库', icon: null },
|
||||
];
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden md:flex items-center space-x-6">
|
||||
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
||||
<Link
|
||||
href="/"
|
||||
className="text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 transition-all duration-200 font-medium relative group px-3 py-2 rounded-lg hover:bg-orange-500/10 dark:hover:bg-orange-400/10"
|
||||
>
|
||||
首页
|
||||
<motion.span
|
||||
className="absolute -bottom-0.5 left-3 right-3 h-0.5 bg-gradient-to-r from-orange-400 to-orange-600"
|
||||
initial={{ scaleX: 0 }}
|
||||
whileHover={{ scaleX: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
||||
<Link
|
||||
href="/skins"
|
||||
className="text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 transition-all duration-200 font-medium relative group px-3 py-2 rounded-lg hover:bg-orange-500/10 dark:hover:bg-orange-400/10"
|
||||
>
|
||||
皮肤库
|
||||
<motion.span
|
||||
className="absolute -bottom-0.5 left-3 right-3 h-0.5 bg-gradient-to-r from-orange-400 to-orange-600"
|
||||
initial={{ scaleX: 0 }}
|
||||
whileHover={{ scaleX: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
|
||||
{/* 用户头像框 - 类似知乎和哔哩哔哩的设计 */}
|
||||
{isAuthenticated ? (
|
||||
<div className="flex items-center space-x-4">
|
||||
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
|
||||
<Link
|
||||
href="/profile"
|
||||
className="flex items-center space-x-3 group"
|
||||
onClick={handleLinkClick}
|
||||
>
|
||||
{user?.avatar ? (
|
||||
<motion.div
|
||||
className="relative"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
>
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user.username}
|
||||
className="w-9 h-9 rounded-full border-2 border-orange-500/30 group-hover:border-orange-500 transition-all duration-200 shadow-md"
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-full bg-gradient-to-br from-orange-400/20 to-orange-600/20"
|
||||
initial={{ opacity: 0 }}
|
||||
whileHover={{ opacity: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
className="relative"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
>
|
||||
<UserCircleIcon className="w-9 h-9 text-gray-400 group-hover:text-orange-500 transition-all duration-200" />
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-full bg-gradient-to-br from-orange-400/20 to-orange-600/20"
|
||||
initial={{ opacity: 0 }}
|
||||
whileHover={{ opacity: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
<motion.span
|
||||
className="text-gray-700 dark:text-gray-300 group-hover:text-orange-500 dark:group-hover:text-orange-400 transition-all duration-200 font-medium"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
>
|
||||
{user?.username}
|
||||
</motion.span>
|
||||
</Link>
|
||||
return (
|
||||
<>
|
||||
<motion.nav
|
||||
ref={navbarRef}
|
||||
style={{
|
||||
y: isHidden ? navbarY : 0,
|
||||
opacity: navbarOpacity
|
||||
}}
|
||||
initial={{ y: 0 }}
|
||||
animate={{ y: isHidden ? -100 : 0 }}
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
className="fixed top-0 left-0 right-0 z-50 transition-all duration-300 bg-white/80 dark:bg-gray-800/80 backdrop-blur-lg border-b border-gray-200/50 dark:border-gray-700/50"
|
||||
style={{ willChange: 'transform' }}
|
||||
>
|
||||
{/* 滚动进度条 */}
|
||||
<motion.div
|
||||
className="absolute bottom-0 left-0 h-0.5 bg-gradient-to-r from-orange-400 to-amber-500"
|
||||
style={{ width: `${scrollProgress}%` }}
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${scrollProgress}%` }}
|
||||
transition={{ duration: 0.1 }}
|
||||
/>
|
||||
|
||||
<div className={`
|
||||
max-w-7xl mx-auto px-4 sm:px-6 lg:px-8
|
||||
${isScrolled ? 'py-3' : 'py-4'}
|
||||
transition-all duration-300
|
||||
`}>
|
||||
<div className="flex justify-between items-center">
|
||||
{/* Logo */}
|
||||
<motion.div
|
||||
className="flex items-center"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Link href="/" className="flex items-center space-x-3 group">
|
||||
<motion.div
|
||||
className="w-10 h-10 bg-gradient-to-br from-orange-400 via-orange-500 to-orange-600 rounded-xl flex items-center justify-center shadow-lg relative overflow-hidden"
|
||||
whileHover={{ rotate: 5, scale: 1.05 }}
|
||||
transition={{ type: 'spring', stiffness: 300 }}
|
||||
>
|
||||
<span className="text-white font-bold text-lg relative z-10">C</span>
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-gradient-to-br from-white/20 to-transparent"
|
||||
initial={{ x: '-100%', y: '-100%' }}
|
||||
whileHover={{ x: '100%', y: '100%' }}
|
||||
transition={{ duration: 0.6 }}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<motion.button
|
||||
onClick={handleLogout}
|
||||
className="relative overflow-hidden border-2 border-orange-500 text-orange-500 hover:text-white font-medium py-2 px-4 rounded-lg transition-all duration-200 group"
|
||||
<motion.span
|
||||
className="text-2xl font-black bg-gradient-to-r from-orange-400 to-orange-600 bg-clip-text text-transparent"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
>
|
||||
CarrotSkin
|
||||
</motion.span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden md:flex items-center space-x-6">
|
||||
{navItems.map((item, index) => (
|
||||
<motion.div
|
||||
key={item.href}
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<motion.span
|
||||
className="absolute inset-0 w-0 bg-gradient-to-r from-orange-400 to-orange-600 transition-all duration-300 group-hover:w-full"
|
||||
initial={{ width: 0 }}
|
||||
whileHover={{ width: '100%' }}
|
||||
/>
|
||||
<span className="relative z-10">退出登录</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
) : (
|
||||
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
||||
<Link
|
||||
href="/auth"
|
||||
className="flex items-center space-x-2 text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 transition-all duration-200 group"
|
||||
>
|
||||
<motion.div
|
||||
className="relative"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
<Link
|
||||
href={item.href}
|
||||
className="text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 transition-all duration-200 font-medium relative group px-3 py-2 rounded-lg hover:bg-orange-500/10 dark:hover:bg-orange-400/10"
|
||||
>
|
||||
<UserCircleIcon className="w-7 h-7 text-gray-400 group-hover:text-orange-500 transition-all duration-200" />
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-full bg-gradient-to-br from-orange-400/20 to-orange-600/20"
|
||||
initial={{ opacity: 0 }}
|
||||
whileHover={{ opacity: 1 }}
|
||||
{item.label}
|
||||
<motion.span
|
||||
className="absolute -bottom-0.5 left-3 right-3 h-0.5 bg-gradient-to-r from-orange-400 to-orange-600"
|
||||
initial={{ scaleX: 0 }}
|
||||
whileHover={{ scaleX: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
</motion.div>
|
||||
<span className="font-medium">登录</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile menu button */}
|
||||
<div className="md:hidden flex items-center">
|
||||
<motion.button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 transition-colors duration-200 p-2"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
{isOpen ? <XMarkIcon className="w-6 h-6" /> : <Bars3Icon className="w-6 h-6" />}
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Navigation */}
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
className="md:hidden bg-white/95 dark:bg-gray-900/95 backdrop-blur-md border-t border-gray-200/50 dark:border-gray-700/50"
|
||||
>
|
||||
<div className="px-4 py-4 space-y-1">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.05 }}
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="block px-4 py-3 text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 rounded-lg transition-all duration-200 font-medium"
|
||||
onClick={handleLinkClick}
|
||||
>
|
||||
首页
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<Link
|
||||
href="/skins"
|
||||
className="block px-4 py-3 text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 rounded-lg transition-all duration-200 font-medium"
|
||||
onClick={handleLinkClick}
|
||||
>
|
||||
皮肤库
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
|
||||
{pathname === item.href && (
|
||||
<motion.span
|
||||
className="absolute -bottom-0.5 left-3 right-3 h-0.5 bg-gradient-to-r from-orange-400 to-orange-600"
|
||||
initial={{ scaleX: 0 }}
|
||||
animate={{ scaleX: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{/* 用户头像框 - 增强的微交互 */}
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
<div className="flex items-center space-x-4">
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<Link
|
||||
href="/profile"
|
||||
className="block px-4 py-3 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 rounded-lg transition-all duration-200"
|
||||
className="flex items-center space-x-3 group"
|
||||
onClick={handleLinkClick}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
{user?.avatar ? (
|
||||
{user?.avatar ? (
|
||||
<motion.div
|
||||
className="relative"
|
||||
whileHover={{ scale: 1.1, rotate: 5 }}
|
||||
>
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user.username}
|
||||
className="w-8 h-8 rounded-full border-2 border-orange-500/30"
|
||||
className="w-9 h-9 rounded-full border-2 border-orange-500/30 group-hover:border-orange-500 transition-all duration-200 shadow-md"
|
||||
/>
|
||||
) : (
|
||||
<UserCircleIcon className="w-8 h-8 text-gray-400" />
|
||||
)}
|
||||
<span className="text-gray-700 dark:text-gray-300 font-medium">{user?.username}</span>
|
||||
</div>
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-full bg-gradient-to-br from-orange-400/20 to-orange-600/20"
|
||||
initial={{ opacity: 0 }}
|
||||
whileHover={{ opacity: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute -inset-1 rounded-full border-2 border-orange-400/50"
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
whileHover={{ scale: 1.2, opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
className="relative"
|
||||
whileHover={{ scale: 1.1, rotate: 5 }}
|
||||
>
|
||||
<UserCircleIcon className="w-9 h-9 text-gray-400 group-hover:text-orange-500 transition-all duration-200" />
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-full bg-gradient-to-br from-orange-400/20 to-orange-600/20"
|
||||
initial={{ opacity: 0 }}
|
||||
whileHover={{ opacity: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
<motion.span
|
||||
className="text-gray-700 dark:text-gray-300 group-hover:text-orange-500 dark:group-hover:text-orange-400 transition-all duration-200 font-medium"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
>
|
||||
{user?.username}
|
||||
</motion.span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
<motion.button
|
||||
onClick={handleLogout}
|
||||
className="relative overflow-hidden border-2 border-orange-500 text-orange-500 hover:text-white font-medium py-2 px-4 rounded-lg transition-all duration-200 group"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
initial={{ opacity: 0, x: 10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.25 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="block w-full text-left px-4 py-3 text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 rounded-lg transition-all duration-200 font-medium"
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
</motion.div>
|
||||
</>
|
||||
<motion.span
|
||||
className="absolute inset-0 w-0 bg-gradient-to-r from-orange-400 to-orange-600 transition-all duration-300 group-hover:w-full"
|
||||
initial={{ width: 0 }}
|
||||
whileHover={{ width: '100%' }}
|
||||
/>
|
||||
<span className="relative z-10">退出登录</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
initial={{ opacity: 0, x: 10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<Link
|
||||
href="/auth"
|
||||
className="flex items-center space-x-2 text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 transition-all duration-200 group"
|
||||
>
|
||||
<motion.div
|
||||
className="relative"
|
||||
whileHover={{ scale: 1.1, rotate: 10 }}
|
||||
>
|
||||
<UserCircleIcon className="w-7 h-7 text-gray-400 group-hover:text-orange-500 transition-all duration-200" />
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-full bg-gradient-to-br from-orange-400/20 to-orange-600/20"
|
||||
initial={{ opacity: 0 }}
|
||||
whileHover={{ opacity: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
</motion.div>
|
||||
<span className="font-medium">登录</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile menu button */}
|
||||
<div className="md:hidden flex items-center">
|
||||
<motion.button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 transition-colors duration-200 p-2"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
animate={{ rotate: isOpen ? 180 : 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
{isOpen ? (
|
||||
<motion.div
|
||||
key="close"
|
||||
initial={{ rotate: -90, opacity: 0 }}
|
||||
animate={{ rotate: 0, opacity: 1 }}
|
||||
exit={{ rotate: 90, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<XMarkIcon className="w-6 h-6" />
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="open"
|
||||
initial={{ rotate: 90, opacity: 0 }}
|
||||
animate={{ rotate: 0, opacity: 1 }}
|
||||
exit={{ rotate: -90, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<Bars3Icon className="w-6 h-6" />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Navigation */}
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
className="md:hidden bg-white/95 dark:bg-gray-900/95 backdrop-blur-md border-t border-gray-200/50 dark:border-gray-700/50 overflow-hidden"
|
||||
>
|
||||
<div className="px-4 py-4 space-y-1">
|
||||
{navItems.map((item, index) => (
|
||||
<motion.div
|
||||
key={item.href}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
>
|
||||
<Link
|
||||
href="/auth"
|
||||
href={item.href}
|
||||
className="block px-4 py-3 text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 rounded-lg transition-all duration-200 font-medium"
|
||||
onClick={handleLinkClick}
|
||||
>
|
||||
登录
|
||||
{item.label}
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.25 }}
|
||||
>
|
||||
<Link
|
||||
href="/auth"
|
||||
className="block px-4 py-3 bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white rounded-lg transition-all duration-200 shadow-lg hover:shadow-xl font-medium text-center"
|
||||
onClick={handleLinkClick}
|
||||
))}
|
||||
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
注册
|
||||
</Link>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
<Link
|
||||
href="/profile"
|
||||
className="block px-4 py-3 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 rounded-lg transition-all duration-200"
|
||||
onClick={handleLinkClick}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
{user?.avatar ? (
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user.username}
|
||||
className="w-8 h-8 rounded-full border-2 border-orange-500/30"
|
||||
/>
|
||||
) : (
|
||||
<UserCircleIcon className="w-8 h-8 text-gray-400" />
|
||||
)}
|
||||
<span className="text-gray-700 dark:text-gray-300 font-medium">{user?.username}</span>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.25 }}
|
||||
>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="block w-full text-left px-4 py-3 text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 rounded-lg transition-all duration-200 font-medium"
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
</motion.div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<Link
|
||||
href="/auth"
|
||||
className="block px-4 py-3 text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 rounded-lg transition-all duration-200 font-medium"
|
||||
onClick={handleLinkClick}
|
||||
>
|
||||
登录
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.25 }}
|
||||
>
|
||||
<Link
|
||||
href="/auth"
|
||||
className="block px-4 py-3 bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white rounded-lg transition-all duration-200 shadow-lg hover:shadow-xl font-medium text-center"
|
||||
onClick={handleLinkClick}
|
||||
>
|
||||
注册
|
||||
</Link>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.nav>
|
||||
|
||||
{/* 返回顶部按钮 */}
|
||||
<AnimatePresence>
|
||||
{showScrollTop && (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, scale: 0.8, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.8, y: 20 }}
|
||||
whileHover={{ scale: 1.1, y: -2 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
onClick={scrollToTop}
|
||||
className="fixed bottom-8 right-8 z-40 bg-gradient-to-r from-orange-500 to-amber-500 text-white p-3 rounded-full shadow-lg hover:shadow-xl transition-all duration-200"
|
||||
>
|
||||
<motion.div
|
||||
animate={{ y: [0, -3, 0] }}
|
||||
transition={{ duration: 1.5, repeat: Infinity }}
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 10l7-7m0 0l7 7m-7-7v18" />
|
||||
</svg>
|
||||
</motion.div>
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.nav>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
177
src/components/PageTransition.tsx
Normal file
177
src/components/PageTransition.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
'use client';
|
||||
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface PageTransitionProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function PageTransition({ children }: PageTransitionProps) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const [isNavigating, setIsNavigating] = useState(false);
|
||||
const [displayChildren, setDisplayChildren] = useState(children);
|
||||
const [pendingChildren, setPendingChildren] = useState<React.ReactNode | null>(null);
|
||||
const navigationTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 监听路由变化
|
||||
useEffect(() => {
|
||||
// 当 pathname 或 searchParams 变化时,表示路由发生了变化
|
||||
if (children !== displayChildren) {
|
||||
setPendingChildren(children);
|
||||
setIsNavigating(true);
|
||||
|
||||
// 清除之前的超时
|
||||
if (navigationTimeoutRef.current) {
|
||||
clearTimeout(navigationTimeoutRef.current);
|
||||
}
|
||||
|
||||
// 模拟加载时间,让 exit 动画有足够时间执行
|
||||
navigationTimeoutRef.current = setTimeout(() => {
|
||||
setDisplayChildren(children);
|
||||
setPendingChildren(null);
|
||||
setIsNavigating(false);
|
||||
}, 500); // 给 exit 动画 300ms + 缓冲时间
|
||||
}
|
||||
}, [pathname, searchParams, children, displayChildren]);
|
||||
|
||||
// 清理超时
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (navigationTimeoutRef.current) {
|
||||
clearTimeout(navigationTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getPageVariants = (direction: 'left' | 'right' | 'up' | 'down' = 'right') => {
|
||||
const directions = {
|
||||
left: { x: -100, y: 0 },
|
||||
right: { x: 100, y: 0 },
|
||||
up: { x: 0, y: -100 },
|
||||
down: { x: 0, y: 100 }
|
||||
};
|
||||
|
||||
const exitDirections = {
|
||||
left: { x: 100, y: 0 },
|
||||
right: { x: -100, y: 0 },
|
||||
up: { x: 0, y: 100 },
|
||||
down: { x: 0, y: -100 }
|
||||
};
|
||||
|
||||
return {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
...directions[direction],
|
||||
scale: 0.9,
|
||||
rotateX: -15
|
||||
},
|
||||
animate: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
rotateX: 0,
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
ease: [0.25, 0.46, 0.45, 0.94],
|
||||
type: "spring",
|
||||
stiffness: 100,
|
||||
damping: 15
|
||||
}
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
...exitDirections[direction],
|
||||
scale: 0.9,
|
||||
rotateX: 15,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
ease: "easeIn"
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const getLoadingVariants = () => ({
|
||||
initial: {
|
||||
opacity: 0,
|
||||
scale: 0.8,
|
||||
y: 20
|
||||
},
|
||||
animate: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
ease: "easeOut"
|
||||
}
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
scale: 0.8,
|
||||
y: -20,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
ease: "easeIn"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence mode="wait">
|
||||
{isNavigating && (
|
||||
<motion.div
|
||||
key="loading"
|
||||
variants={getLoadingVariants()}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm pointer-events-none"
|
||||
>
|
||||
<div className="text-center">
|
||||
<motion.div
|
||||
animate={{
|
||||
rotate: 360,
|
||||
scale: [1, 1.1, 1]
|
||||
}}
|
||||
transition={{
|
||||
rotate: { duration: 1, repeat: Infinity, ease: "linear" },
|
||||
scale: { duration: 1.5, repeat: Infinity, ease: "easeInOut" }
|
||||
}}
|
||||
className="w-12 h-12 border-4 border-orange-500 border-t-transparent rounded-full mx-auto mb-4"
|
||||
/>
|
||||
<motion.p
|
||||
className="text-lg font-medium text-gray-700 dark:text-gray-300"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
页面切换中...
|
||||
</motion.p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={pathname + searchParams.toString()}
|
||||
variants={getPageVariants()}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
className="min-h-screen"
|
||||
>
|
||||
{displayChildren}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -41,7 +41,7 @@ export default function ScrollToTop() {
|
||||
exit={{ opacity: 0, scale: 0.8, y: 20 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
onClick={scrollToTop}
|
||||
className="fixed bottom-6 right-6 w-12 h-12 bg-gradient-to-br from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white rounded-full shadow-lg hover:shadow-xl transition-all duration-200 flex items-center justify-center z-[100] group"
|
||||
className="fixed bottom-6 right-6 w-12 h-12 bg-gradient-to-br from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white rounded-full shadow-lg hover:shadow-xl transition-all duration-200 flex items-center justify-center z-40 group"
|
||||
whileHover={{ scale: 1.1, y: -2 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
|
||||
446
src/components/SkinCard.tsx
Normal file
446
src/components/SkinCard.tsx
Normal file
@@ -0,0 +1,446 @@
|
||||
'use client';
|
||||
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useState } from 'react';
|
||||
import { EyeIcon, ArrowDownTrayIcon, HeartIcon } from '@heroicons/react/24/outline';
|
||||
import { HeartIcon as HeartIconSolid } from '@heroicons/react/24/solid';
|
||||
import SkinViewer from './SkinViewer';
|
||||
import type { Texture } from '@/lib/api';
|
||||
|
||||
interface SkinCardProps {
|
||||
texture: Texture;
|
||||
isFavorited?: boolean;
|
||||
onViewDetails: (texture: Texture) => void;
|
||||
onToggleFavorite?: (textureId: number) => void;
|
||||
onDownload?: (texture: Texture) => void;
|
||||
showVisibilityBadge?: boolean;
|
||||
showActions?: boolean;
|
||||
customActions?: React.ReactNode;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
export default function SkinCard({
|
||||
texture,
|
||||
isFavorited = false,
|
||||
onViewDetails,
|
||||
onToggleFavorite,
|
||||
onDownload,
|
||||
showVisibilityBadge = true,
|
||||
showActions = true,
|
||||
customActions,
|
||||
index = 0
|
||||
}: SkinCardProps) {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const [isFavoriting, setIsFavoriting] = useState(false);
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (isDownloading) return;
|
||||
|
||||
setIsDownloading(true);
|
||||
|
||||
// 模拟下载延迟
|
||||
setTimeout(() => {
|
||||
if (onDownload) {
|
||||
onDownload(texture);
|
||||
} else {
|
||||
window.open(texture.url, '_blank');
|
||||
}
|
||||
setIsDownloading(false);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleToggleFavorite = async () => {
|
||||
if (isFavoriting || !onToggleFavorite) return;
|
||||
|
||||
setIsFavoriting(true);
|
||||
|
||||
// 模拟收藏操作延迟
|
||||
setTimeout(() => {
|
||||
onToggleFavorite(texture.id);
|
||||
setIsFavoriting(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const getCardVariants = () => ({
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
y: 50,
|
||||
scale: 0.9,
|
||||
rotateX: -15
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
rotateX: 0,
|
||||
transition: {
|
||||
duration: 0.6,
|
||||
delay: index * 0.1,
|
||||
ease: [0.25, 0.46, 0.45, 0.94],
|
||||
type: "spring",
|
||||
stiffness: 100,
|
||||
damping: 15
|
||||
}
|
||||
},
|
||||
hover: {
|
||||
scale: 1.03,
|
||||
y: -8,
|
||||
rotateX: 5,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
ease: "easeOut"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const getActionButtonVariants = () => ({
|
||||
initial: { scale: 0, opacity: 0 },
|
||||
hover: {
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
delay: 0.1,
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 20
|
||||
}
|
||||
},
|
||||
tap: {
|
||||
scale: 0.9,
|
||||
transition: { duration: 0.1 }
|
||||
}
|
||||
});
|
||||
|
||||
const getTagVariants = () => ({
|
||||
initial: { scale: 0.8, opacity: 0 },
|
||||
animate: {
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
delay: 0.2 + index * 0.05,
|
||||
type: "spring",
|
||||
stiffness: 200,
|
||||
damping: 15
|
||||
}
|
||||
},
|
||||
hover: {
|
||||
scale: 1.05,
|
||||
transition: { duration: 0.2 }
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={getCardVariants()}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
whileHover="hover"
|
||||
onHoverStart={() => setIsHovered(true)}
|
||||
onHoverEnd={() => setIsHovered(false)}
|
||||
className="group relative bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm rounded-xl shadow-lg hover:shadow-2xl transition-all duration-300 overflow-hidden border border-white/20 dark:border-gray-700/30"
|
||||
style={{
|
||||
perspective: '1000px',
|
||||
transformStyle: 'preserve-3d'
|
||||
}}
|
||||
>
|
||||
{/* 3D预览区域 */}
|
||||
<div className="relative aspect-square bg-gradient-to-br from-orange-50 to-amber-50 dark:from-gray-700 dark:to-gray-600 overflow-hidden">
|
||||
{/* 加载状态 */}
|
||||
<AnimatePresence>
|
||||
{!imageLoaded && (
|
||||
<motion.div
|
||||
initial={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-orange-100 to-amber-100 dark:from-gray-600 dark:to-gray-500"
|
||||
>
|
||||
<motion.div
|
||||
animate={{
|
||||
rotate: 360,
|
||||
scale: [1, 1.1, 1]
|
||||
}}
|
||||
transition={{
|
||||
rotate: { duration: 2, repeat: Infinity, ease: "linear" },
|
||||
scale: { duration: 1.5, repeat: Infinity, ease: "easeInOut" }
|
||||
}}
|
||||
className="w-12 h-12 border-4 border-orange-300 dark:border-orange-600 border-t-transparent rounded-full"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{texture.type === 'SKIN' ? (
|
||||
<SkinViewer
|
||||
skinUrl={texture.url}
|
||||
isSlim={texture.is_slim}
|
||||
width={300}
|
||||
height={300}
|
||||
className={`w-full h-full transition-all duration-500 ${
|
||||
imageLoaded ? 'opacity-100 scale-100' : 'opacity-0 scale-95'
|
||||
} ${isHovered ? 'scale-110' : ''}`}
|
||||
autoRotate={isHovered}
|
||||
walking={false}
|
||||
onLoad={() => setImageLoaded(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<motion.div
|
||||
className="text-center"
|
||||
animate={isHovered ? { y: [-5, 5, -5] } : {}}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: "easeInOut" }}
|
||||
>
|
||||
<motion.div
|
||||
className="w-24 h-24 mx-auto mb-2 bg-white dark:bg-gray-800 rounded-xl shadow-lg flex items-center justify-center"
|
||||
whileHover={{ scale: 1.1, rotate: 10 }}
|
||||
transition={{ type: 'spring', stiffness: 300 }}
|
||||
animate={imageLoaded ? {} : { scale: [0.8, 1, 0.8] }}
|
||||
transition={imageLoaded ? {} : { duration: 1.5, repeat: Infinity }}
|
||||
>
|
||||
<span className="text-2xl">🧥</span>
|
||||
</motion.div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 font-medium">披风</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 悬停操作按钮 */}
|
||||
{showActions && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: isHovered ? 1 : 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="absolute inset-0 bg-gradient-to-br from-black/40 via-black/30 to-transparent flex items-center justify-center"
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<motion.button
|
||||
variants={getActionButtonVariants()}
|
||||
initial="initial"
|
||||
animate={isHovered ? "hover" : "initial"}
|
||||
whileTap="tap"
|
||||
onClick={() => onViewDetails(texture)}
|
||||
className="bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white p-3 rounded-full shadow-lg transition-all duration-200 backdrop-blur-sm"
|
||||
title="详细预览"
|
||||
>
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.1, 1] }}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
>
|
||||
<EyeIcon className="w-5 h-5" />
|
||||
</motion.div>
|
||||
</motion.button>
|
||||
|
||||
{onDownload !== false && (
|
||||
<motion.button
|
||||
variants={getActionButtonVariants()}
|
||||
initial="initial"
|
||||
animate={isHovered ? "hover" : "initial"}
|
||||
whileTap="tap"
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading}
|
||||
className={`p-3 rounded-full shadow-lg transition-all duration-200 backdrop-blur-sm ${
|
||||
isDownloading
|
||||
? 'bg-gray-500 cursor-not-allowed'
|
||||
: 'bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white'
|
||||
}`}
|
||||
title={isDownloading ? '下载中...' : '查看原图'}
|
||||
>
|
||||
{isDownloading ? (
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||
>
|
||||
<ArrowDownTrayIcon className="w-5 h-5" />
|
||||
</motion.div>
|
||||
) : (
|
||||
<ArrowDownTrayIcon className="w-5 h-5" />
|
||||
)}
|
||||
</motion.button>
|
||||
)}
|
||||
|
||||
{onToggleFavorite && (
|
||||
<motion.button
|
||||
variants={getActionButtonVariants()}
|
||||
initial="initial"
|
||||
animate={isHovered ? "hover" : "initial"}
|
||||
whileTap="tap"
|
||||
onClick={handleToggleFavorite}
|
||||
disabled={isFavoriting}
|
||||
className={`p-3 rounded-full shadow-lg transition-all duration-200 backdrop-blur-sm ${
|
||||
isFavoriting
|
||||
? 'cursor-not-allowed opacity-75'
|
||||
: isFavorited
|
||||
? 'bg-gradient-to-r from-red-500 to-pink-500 hover:from-red-600 hover:to-pink-600 text-white'
|
||||
: 'bg-gradient-to-r from-gray-500 to-gray-600 hover:from-gray-600 hover:to-gray-700 text-white'
|
||||
}`}
|
||||
title={isFavorited ? '取消收藏' : '添加收藏'}
|
||||
>
|
||||
{isFavoriting ? (
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.2, 1] }}
|
||||
transition={{ duration: 0.5, repeat: Infinity }}
|
||||
>
|
||||
<HeartIcon className="w-5 h-5" />
|
||||
</motion.div>
|
||||
) : isFavorited ? (
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 300 }}
|
||||
>
|
||||
<HeartIconSolid className="w-5 h-5" />
|
||||
</motion.div>
|
||||
) : (
|
||||
<HeartIcon className="w-5 h-5" />
|
||||
)}
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 标签 */}
|
||||
<div className="absolute top-3 right-3 flex gap-1.5">
|
||||
<motion.span
|
||||
variants={getTagVariants()}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
whileHover="hover"
|
||||
className={`px-2 py-1 text-white text-xs rounded-full font-medium backdrop-blur-sm shadow-lg ${
|
||||
texture.type === 'SKIN' ? 'bg-blue-500/80' : 'bg-purple-500/80'
|
||||
}`}
|
||||
>
|
||||
{texture.type === 'SKIN' ? '皮肤' : '披风'}
|
||||
</motion.span>
|
||||
{texture.is_slim && (
|
||||
<motion.span
|
||||
variants={getTagVariants()}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
whileHover="hover"
|
||||
className="px-2 py-1 bg-pink-500/80 text-white text-xs rounded-full font-medium backdrop-blur-sm shadow-lg"
|
||||
>
|
||||
细臂
|
||||
</motion.span>
|
||||
)}
|
||||
{showVisibilityBadge && !texture.is_public && (
|
||||
<motion.span
|
||||
variants={getTagVariants()}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
whileHover="hover"
|
||||
className="px-2 py-1 bg-gray-800/80 text-white text-xs rounded-full font-medium backdrop-blur-sm shadow-lg"
|
||||
>
|
||||
私密
|
||||
</motion.span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 悬停时的光效 */}
|
||||
<AnimatePresence>
|
||||
{isHovered && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute inset-0 bg-gradient-to-br from-orange-400/10 via-transparent to-amber-400/10 pointer-events-none"
|
||||
style={{
|
||||
background: `radial-gradient(circle at ${50}% ${50}%, rgba(249, 115, 22, 0.1) 0%, transparent 70%)`
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Texture Info */}
|
||||
<div className="p-4">
|
||||
<motion.h3
|
||||
className="font-semibold text-gray-900 dark:text-white mb-1 truncate"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 + 0.3 }}
|
||||
>
|
||||
{texture.name}
|
||||
</motion.h3>
|
||||
{texture.description && (
|
||||
<motion.p
|
||||
className="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2 leading-relaxed"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 + 0.4 }}
|
||||
>
|
||||
{texture.description}
|
||||
</motion.p>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<motion.div
|
||||
className="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400 mb-4"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 + 0.5 }}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
{onToggleFavorite && (
|
||||
<motion.span
|
||||
className="flex items-center space-x-1"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
animate={isFavorited ? { scale: [1, 1.2, 1] } : {}}
|
||||
transition={isFavorited ? { duration: 0.3 } : {}}
|
||||
>
|
||||
<motion.div
|
||||
animate={isFavorited ? { scale: [1, 1.2, 1] } : {}}
|
||||
transition={isFavorited ? { duration: 0.5 } : {}}
|
||||
>
|
||||
<HeartIcon className="w-4 h-4 text-red-400" />
|
||||
</motion.div>
|
||||
<span className="font-medium">{texture.favorite_count || 0}</span>
|
||||
</motion.span>
|
||||
)}
|
||||
<motion.span
|
||||
className="flex items-center space-x-1"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
>
|
||||
<ArrowDownTrayIcon className="w-4 h-4 text-blue-400" />
|
||||
<span className="font-medium">{texture.download_count || 0}</span>
|
||||
</motion.span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{texture.uploader && (
|
||||
<motion.span
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: index * 0.1 + 0.6 }}
|
||||
>
|
||||
by {texture.uploader.username}
|
||||
</motion.span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Custom Actions */}
|
||||
{customActions && (
|
||||
<motion.div
|
||||
className="flex gap-2"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 + 0.6 }}
|
||||
>
|
||||
{customActions}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部装饰条 */}
|
||||
<motion.div
|
||||
className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-orange-400 to-amber-500"
|
||||
initial={{ scaleX: 0 }}
|
||||
animate={{ scaleX: 1 }}
|
||||
transition={{ delay: index * 0.1 + 0.7, duration: 0.5 }}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { XMarkIcon, PlayIcon, PauseIcon, ArrowPathIcon, ForwardIcon} from '@heroicons/react/24/outline';
|
||||
import { motion, AnimatePresence, useSpring, useTransform } from 'framer-motion';
|
||||
import { XMarkIcon, PlayIcon, PauseIcon, ArrowPathIcon, ForwardIcon } from '@heroicons/react/24/outline';
|
||||
import SkinViewer from './SkinViewer';
|
||||
|
||||
interface SkinDetailModalProps {
|
||||
@@ -22,13 +22,21 @@ interface SkinDetailModalProps {
|
||||
username: string;
|
||||
};
|
||||
} | null;
|
||||
isExternalPreview?: boolean;
|
||||
}
|
||||
|
||||
export default function SkinDetailModal({ isOpen, onClose, texture }: SkinDetailModalProps) {
|
||||
export default function SkinDetailModal({ isOpen, onClose, texture, isExternalPreview = false }: SkinDetailModalProps) {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentAnimation, setCurrentAnimation] = useState<'idle' | 'walking' | 'running' | 'jumping'>('idle');
|
||||
const [autoRotate, setAutoRotate] = useState(true);
|
||||
const [currentAnimation, setCurrentAnimation] = useState<'idle' | 'walking' | 'running' | 'swimming'>('idle');
|
||||
const [autoRotate, setAutoRotate] = useState(!isExternalPreview);
|
||||
const [rotation, setRotation] = useState(true);
|
||||
const [isMinimized, setIsMinimized] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'preview' | 'info' | 'settings'>('preview');
|
||||
|
||||
// 弹簧动画配置
|
||||
const springConfig = { stiffness: 300, damping: 30 };
|
||||
const scale = useSpring(1, springConfig);
|
||||
const rotate = useSpring(0, springConfig);
|
||||
|
||||
// 重置状态当对话框关闭时
|
||||
useEffect(() => {
|
||||
@@ -37,6 +45,8 @@ export default function SkinDetailModal({ isOpen, onClose, texture }: SkinDetail
|
||||
setCurrentAnimation('idle');
|
||||
setAutoRotate(true);
|
||||
setRotation(true);
|
||||
setIsMinimized(false);
|
||||
setActiveTab('preview');
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
@@ -63,14 +73,101 @@ export default function SkinDetailModal({ isOpen, onClose, texture }: SkinDetail
|
||||
setCurrentAnimation('running');
|
||||
break;
|
||||
case '4':
|
||||
setCurrentAnimation('jumping');
|
||||
setCurrentAnimation('swimming');
|
||||
break;
|
||||
case 'm':
|
||||
case 'M':
|
||||
setIsMinimized(!isMinimized);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose, isPlaying]);
|
||||
}, [isOpen, onClose, isPlaying, isMinimized]);
|
||||
|
||||
// 动画控制函数
|
||||
const handleAnimationChange = (animation: 'idle' | 'walking' | 'running' | 'swimming') => {
|
||||
setCurrentAnimation(animation);
|
||||
// 添加触觉反馈
|
||||
if (navigator.vibrate) {
|
||||
navigator.vibrate(50);
|
||||
}
|
||||
};
|
||||
|
||||
const getModalVariants = () => ({
|
||||
initial: {
|
||||
opacity: 0,
|
||||
scale: 0.7,
|
||||
rotateX: -15,
|
||||
y: 50
|
||||
},
|
||||
animate: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
rotateX: 0,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.5,
|
||||
ease: [0.25, 0.46, 0.45, 0.94],
|
||||
type: "spring",
|
||||
stiffness: 100,
|
||||
damping: 15
|
||||
}
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
scale: 0.7,
|
||||
rotateX: 15,
|
||||
y: 50,
|
||||
transition: {
|
||||
duration: 0.3,
|
||||
ease: "easeIn"
|
||||
}
|
||||
},
|
||||
minimized: {
|
||||
scale: 0.8,
|
||||
y: window.innerHeight - 200,
|
||||
x: window.innerWidth - 300,
|
||||
width: 280,
|
||||
height: 150,
|
||||
transition: {
|
||||
duration: 0.4,
|
||||
ease: "easeInOut"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const getAnimationButtonVariants = (isActive: boolean) => ({
|
||||
initial: { scale: 0.9, opacity: 0.8 },
|
||||
animate: {
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 20
|
||||
}
|
||||
},
|
||||
hover: {
|
||||
scale: 1.05,
|
||||
transition: { duration: 0.2 }
|
||||
},
|
||||
tap: {
|
||||
scale: 0.95,
|
||||
transition: { duration: 0.1 }
|
||||
},
|
||||
active: {
|
||||
scale: 1.02,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 25
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!texture) return null;
|
||||
|
||||
@@ -81,235 +178,306 @@ export default function SkinDetailModal({ isOpen, onClose, texture }: SkinDetail
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
isolation: 'isolate'
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.9, opacity: 0 }}
|
||||
variants={getModalVariants()}
|
||||
initial="initial"
|
||||
animate={isMinimized ? "minimized" : "animate"}
|
||||
exit="exit"
|
||||
transition={{ type: "spring", damping: 20, stiffness: 300 }}
|
||||
className="relative w-full max-w-6xl h-[90vh] bg-white/95 dark:bg-gray-800/95 rounded-2xl shadow-2xl overflow-hidden"
|
||||
className={`relative bg-white/95 dark:bg-gray-800/95 rounded-2xl shadow-2xl overflow-hidden border border-white/20 dark:border-gray-700/50 backdrop-blur-lg ${
|
||||
isMinimized ? 'fixed bottom-4 right-4' : 'w-full max-w-6xl h-[90vh]'
|
||||
}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="absolute top-0 left-0 right-0 z-10 bg-gradient-to-r from-white/90 to-gray-50/90 dark:from-gray-800/90 dark:to-gray-700/90 backdrop-blur-md border-b border-gray-200/50 dark:border-gray-600/50 p-4">
|
||||
<motion.div
|
||||
className="absolute top-0 left-0 right-0 bg-gradient-to-r from-white/90 to-orange-50/90 dark:from-gray-800/90 dark:to-gray-700/90 backdrop-blur-md border-b border-orange-200/50 dark:border-gray-600/50 p-4 z-10"
|
||||
initial={{ y: -100 }}
|
||||
animate={{ y: 0 }}
|
||||
transition={{ delay: 0.2, duration: 0.4 }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
<motion.h2
|
||||
className="text-2xl font-bold bg-gradient-to-r from-orange-500 to-amber-500 bg-clip-text text-transparent"
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
{texture.name}
|
||||
</h2>
|
||||
</motion.h2>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className={`px-3 py-1 text-sm rounded-full font-medium ${
|
||||
texture.type === 'SKIN'
|
||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
|
||||
: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300'
|
||||
}`}>
|
||||
<motion.span
|
||||
className={`px-3 py-1 text-sm rounded-full font-medium ${
|
||||
texture.type === 'SKIN'
|
||||
? 'bg-gradient-to-r from-orange-100 to-amber-100 text-orange-800 dark:from-orange-900/30 dark:to-amber-900/30 dark:text-orange-300'
|
||||
: 'bg-gradient-to-r from-purple-100 to-pink-100 text-purple-800 dark:from-purple-900/30 dark:to-pink-900/30 dark:text-purple-300'
|
||||
}`}
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 0.4, type: "spring", stiffness: 200 }}
|
||||
>
|
||||
{texture.type === 'SKIN' ? '皮肤' : '披风'}
|
||||
</span>
|
||||
</motion.span>
|
||||
{texture.is_slim && (
|
||||
<span className="px-3 py-1 bg-pink-100 text-pink-800 dark:bg-pink-900/30 dark:text-pink-300 text-sm rounded-full font-medium">
|
||||
<motion.span
|
||||
className="px-3 py-1 bg-gradient-to-r from-pink-100 to-rose-100 text-pink-800 dark:from-pink-900/30 dark:to-rose-900/30 text-sm rounded-full font-medium"
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 0.5, type: "spring", stiffness: 200 }}
|
||||
>
|
||||
细臂
|
||||
</span>
|
||||
</motion.span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<motion.button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
<div className="flex items-center space-x-2">
|
||||
<motion.button
|
||||
onClick={() => setIsMinimized(!isMinimized)}
|
||||
className="p-2 text-gray-500 hover:text-orange-500 dark:text-gray-400 dark:hover:text-orange-400 rounded-full hover:bg-orange-100 dark:hover:bg-orange-900/20 transition-all duration-200"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
initial={{ opacity: 0, rotate: -180 }}
|
||||
animate={{ opacity: 1, rotate: 0 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
title={isMinimized ? "最大化" : "最小化"}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={isMinimized ? "M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" : "M9 9V4.5M9 9H4.5M9 9L3.5 3.5M15 9v4.5M15 9h4.5M15 9l5.5 5.5M9 15v4.5M9 15H4.5M9 15l-5.5 5.5M15 15v4.5M15 15h4.5m-4.5 0l5.5 5.5"} />
|
||||
</svg>
|
||||
</motion.button>
|
||||
<motion.button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-500 hover:text-orange-500 dark:text-gray-400 dark:hover:text-orange-400 rounded-full hover:bg-orange-100 dark:hover:bg-orange-900/20 transition-all duration-200"
|
||||
whileHover={{ scale: 1.1, rotate: 90 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
initial={{ opacity: 0, rotate: -180 }}
|
||||
animate={{ opacity: 1, rotate: 0 }}
|
||||
transition={{ delay: 0.7 }}
|
||||
>
|
||||
<XMarkIcon className="w-6 h-6" />
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{!isMinimized && (
|
||||
<div className="flex h-full pt-20">
|
||||
{/* 3D 预览区域 */}
|
||||
<motion.div
|
||||
className="flex-1 flex items-center justify-center p-8 bg-gradient-to-br from-orange-50/30 to-amber-50/30 dark:from-gray-900/30 dark:to-gray-800/30"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.3, duration: 0.5 }}
|
||||
>
|
||||
<XMarkIcon className="w-6 h-6" />
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-full pt-20">
|
||||
{/* 3D 预览区域 */}
|
||||
<div className="flex-1 flex items-center justify-center p-8">
|
||||
<div className="w-full h-full max-w-2xl max-h-2xl">
|
||||
<SkinViewer
|
||||
skinUrl={texture.url}
|
||||
isSlim={texture.is_slim}
|
||||
width={600}
|
||||
height={600}
|
||||
className="w-full h-full rounded-xl shadow-lg"
|
||||
autoRotate={autoRotate}
|
||||
walking={currentAnimation === 'walking'}
|
||||
running={currentAnimation === 'running'}
|
||||
jumping={currentAnimation === 'jumping'}
|
||||
rotation={rotation}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 控制面板 */}
|
||||
<div className="w-80 bg-gray-50/80 dark:bg-gray-900/80 backdrop-blur-md border-l border-gray-200/50 dark:border-gray-600/50 p-6 space-y-6 overflow-y-auto">
|
||||
{/* 动画控制 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<PlayIcon className="w-5 h-5 mr-2" />
|
||||
动画控制
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<motion.button
|
||||
onClick={() => setCurrentAnimation('idle')}
|
||||
className={`p-3 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||
currentAnimation === 'idle'
|
||||
? 'bg-orange-500 text-white shadow-lg'
|
||||
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600'
|
||||
}`}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
<div className="w-full h-full max-w-2xl max-h-2xl">
|
||||
<motion.div
|
||||
animate={{
|
||||
rotateY: autoRotate ? 360 : 0,
|
||||
scale: isPlaying ? 1.02 : 1
|
||||
}}
|
||||
transition={{
|
||||
rotateY: { duration: 10, repeat: Infinity, ease: "linear" },
|
||||
scale: { duration: 0.2 }
|
||||
}}
|
||||
>
|
||||
静止
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
onClick={() => setCurrentAnimation('walking')}
|
||||
className={`p-3 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||
currentAnimation === 'walking'
|
||||
? 'bg-orange-500 text-white shadow-lg'
|
||||
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600'
|
||||
}`}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
步行
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
onClick={() => setCurrentAnimation('running')}
|
||||
className={`p-3 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||
currentAnimation === 'running'
|
||||
? 'bg-orange-500 text-white shadow-lg'
|
||||
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600'
|
||||
}`}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
跑步
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
onClick={() => setCurrentAnimation('jumping')}
|
||||
className={`p-3 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||
currentAnimation === 'jumping'
|
||||
? 'bg-orange-500 text-white shadow-lg'
|
||||
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600'
|
||||
}`}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
跳跃
|
||||
</motion.button>
|
||||
<SkinViewer
|
||||
skinUrl={texture.url}
|
||||
isSlim={texture.is_slim}
|
||||
width={600}
|
||||
height={600}
|
||||
className="w-full h-full rounded-xl shadow-2xl border-2 border-white/50 dark:border-gray-700/50"
|
||||
autoRotate={autoRotate}
|
||||
walking={currentAnimation === 'walking'}
|
||||
running={currentAnimation === 'running'}
|
||||
jumping={currentAnimation === 'swimming'}
|
||||
rotation={rotation}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 视角控制 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<ArrowPathIcon className="w-5 h-5 mr-2" />
|
||||
视角控制
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<motion.button
|
||||
onClick={() => setAutoRotate(!autoRotate)}
|
||||
className={`w-full p-3 rounded-lg text-sm font-medium transition-all duration-200 flex items-center justify-center ${
|
||||
autoRotate
|
||||
? 'bg-blue-500 text-white shadow-lg'
|
||||
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600'
|
||||
}`}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
{/* 控制面板 */}
|
||||
<motion.div
|
||||
className="w-80 bg-gradient-to-b from-orange-50/80 to-amber-50/80 dark:from-gray-900/80 dark:to-gray-800/80 backdrop-blur-md border-l border-orange-200/50 dark:border-gray-600/50 p-6 space-y-6 overflow-y-auto custom-scrollbar"
|
||||
initial={{ opacity: 0, x: 50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.4, duration: 0.5 }}
|
||||
>
|
||||
{/* 动画控制 */}
|
||||
<motion.div className="space-y-4">
|
||||
<motion.h3
|
||||
className="text-lg font-semibold bg-gradient-to-r from-orange-500 to-amber-500 bg-clip-text text-transparent flex items-center"
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
>
|
||||
<ArrowPathIcon className="w-4 h-4 mr-2" />
|
||||
{autoRotate ? '停止旋转' : '自动旋转'}
|
||||
</motion.button>
|
||||
<PlayIcon className="w-5 h-5 mr-2" />
|
||||
动画控制
|
||||
</motion.h3>
|
||||
|
||||
<motion.button
|
||||
onClick={() => setRotation(!rotation)}
|
||||
className={`w-full p-3 rounded-lg text-sm font-medium transition-all duration-200 flex items-center justify-center ${
|
||||
rotation
|
||||
? 'bg-green-500 text-white shadow-lg'
|
||||
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600'
|
||||
}`}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<ForwardIcon className="w-4 h-4 mr-2" />
|
||||
{rotation ? '禁用控制' : '启用控制'}
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 皮肤信息 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<span className="w-5 h-5 mr-2">📋</span>
|
||||
皮肤信息
|
||||
</h3>
|
||||
|
||||
<div className="bg-white/50 dark:bg-gray-800/50 rounded-lg p-4 space-y-3">
|
||||
{texture.description && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">描述</p>
|
||||
<p className="text-sm text-gray-900 dark:text-white">{texture.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-gray-600 dark:text-gray-400">收藏数</p>
|
||||
<p className="font-semibold text-gray-900 dark:text-white">{texture.favorite_count || 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-600 dark:text-gray-400">下载数</p>
|
||||
<p className="font-semibold text-gray-900 dark:text-white">{texture.download_count || 0}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{[
|
||||
{ key: 'idle', label: '静止', icon: null },
|
||||
{ key: 'walking', label: '步行', icon: null },
|
||||
{ key: 'running', label: '跑步', icon: null },
|
||||
{ key: 'swimming', label: '游泳', icon: null }
|
||||
].map((anim, i) => (
|
||||
<motion.button
|
||||
key={anim.key}
|
||||
variants={getAnimationButtonVariants(currentAnimation === anim.key)}
|
||||
initial="initial"
|
||||
animate={currentAnimation === anim.key ? "active" : "animate"}
|
||||
whileHover="hover"
|
||||
whileTap="tap"
|
||||
onClick={() => handleAnimationChange(anim.key as any)}
|
||||
className={`p-3 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||
currentAnimation === anim.key
|
||||
? 'bg-gradient-to-r from-orange-500 to-amber-500 text-white shadow-lg transform scale-105'
|
||||
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-orange-100 dark:hover:bg-orange-900/20 border border-orange-200 dark:border-gray-600 hover:border-orange-300 dark:hover:border-orange-500'
|
||||
}`}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.6 + i * 0.1 }}
|
||||
>
|
||||
{anim.label}
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{texture.uploader && (
|
||||
<div>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">上传者</p>
|
||||
<p className="font-semibold text-gray-900 dark:text-white">{texture.uploader.username}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{texture.created_at && (
|
||||
<div>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">上传时间</p>
|
||||
<p className="font-semibold text-gray-900 dark:text-white">
|
||||
{new Date(texture.created_at).toLocaleDateString('zh-CN')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 快捷键提示 */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
|
||||
<h4 className="text-sm font-semibold text-blue-800 dark:text-blue-300 mb-2 flex items-center">
|
||||
<span className="w-4 h-4 mr-2">⌨️</span>
|
||||
快捷键
|
||||
</h4>
|
||||
<div className="text-xs text-blue-700 dark:text-blue-400 space-y-1">
|
||||
<p><kbd className="px-1 py-0.5 bg-white/50 dark:bg-gray-800/50 rounded">空格</kbd> 播放/暂停</p>
|
||||
<p><kbd className="px-1 py-0.5 bg-white/50 dark:bg-gray-800/50 rounded">1</kbd> 静止</p>
|
||||
<p><kbd className="px-1 py-0.5 bg-white/50 dark:bg-gray-800/50 rounded">2</kbd> 步行</p>
|
||||
<p><kbd className="px-1 py-0.5 bg-white/50 dark:bg-gray-800/50 rounded">3</kbd> 跑步</p>
|
||||
<p><kbd className="px-1 py-0.5 bg-white/50 dark:bg-gray-800/50 rounded">4</kbd> 跳跃</p>
|
||||
<p><kbd className="px-1 py-0.5 bg-white/50 dark:bg-gray-800/50 rounded">ESC</kbd> 关闭</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* 信息面板 */}
|
||||
<motion.div
|
||||
className="space-y-4"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.8 }}
|
||||
>
|
||||
<h3 className="text-lg font-semibold bg-gradient-to-r from-orange-500 to-amber-500 bg-clip-text text-transparent">
|
||||
皮肤信息
|
||||
</h3>
|
||||
|
||||
{texture.description && (
|
||||
<motion.div
|
||||
className="bg-white/50 dark:bg-gray-700/50 rounded-lg p-3"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.9 }}
|
||||
>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">
|
||||
{texture.description}
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
{texture.uploader && (
|
||||
<motion.div
|
||||
className="flex justify-between items-center"
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 1.0 }}
|
||||
>
|
||||
<span className="text-gray-600 dark:text-gray-400">上传者:</span>
|
||||
<span className="font-medium text-gray-800 dark:text-gray-200">{texture.uploader.username}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{texture.created_at && (
|
||||
<motion.div
|
||||
className="flex justify-between items-center"
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 1.1 }}
|
||||
>
|
||||
<span className="text-gray-600 dark:text-gray-400">上传时间:</span>
|
||||
<span className="font-medium text-gray-800 dark:text-gray-200">
|
||||
{new Date(texture.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<motion.div
|
||||
className="flex justify-between items-center"
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 1.2 }}
|
||||
>
|
||||
<span className="text-gray-600 dark:text-gray-400">收藏数:</span>
|
||||
<span className="font-medium text-gray-800 dark:text-gray-200">{texture.favorite_count || 0}</span>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="flex justify-between items-center"
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 1.3 }}
|
||||
>
|
||||
<span className="text-gray-600 dark:text-gray-400">下载数:</span>
|
||||
<span className="font-medium text-gray-800 dark:text-gray-200">{texture.download_count || 0}</span>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 快捷键提示 */}
|
||||
<motion.div
|
||||
className="bg-white/30 dark:bg-gray-700/30 rounded-lg p-3 text-xs text-gray-600 dark:text-gray-400"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 1.4 }}
|
||||
>
|
||||
<p className="font-medium mb-1">快捷键:</p>
|
||||
<p>空格 - 播放/暂停 | 1-4 - 切换动画 | M - 最小化 | ESC - 关闭</p>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 最小化时的内容 */}
|
||||
{isMinimized && (
|
||||
<motion.div
|
||||
className="flex items-center justify-center h-full p-4"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<div className="text-center">
|
||||
<motion.div
|
||||
animate={{ rotate: autoRotate ? 360 : 0 }}
|
||||
transition={{ rotate: { duration: 5, repeat: Infinity, ease: "linear" } }}
|
||||
className="w-20 h-20 mx-auto mb-2"
|
||||
>
|
||||
<SkinViewer
|
||||
skinUrl={texture.url}
|
||||
isSlim={texture.is_slim}
|
||||
width={80}
|
||||
height={80}
|
||||
autoRotate={autoRotate}
|
||||
walking={currentAnimation === 'walking'}
|
||||
running={currentAnimation === 'running'}
|
||||
jumping={currentAnimation === 'swimming'}
|
||||
/>
|
||||
</motion.div>
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 truncate">
|
||||
{texture.name}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ interface SkinViewerProps {
|
||||
running?: boolean; // 新增:跑步动画
|
||||
jumping?: boolean; // 新增:跳跃动画
|
||||
rotation?: boolean; // 新增:旋转控制
|
||||
isExternalPreview?: boolean; // 新增:是否为外部预览
|
||||
}
|
||||
|
||||
export default function SkinViewer({
|
||||
@@ -29,6 +30,7 @@ export default function SkinViewer({
|
||||
running = false,
|
||||
jumping = false,
|
||||
rotation = true,
|
||||
isExternalPreview = false, // 新增:默认为false
|
||||
}: SkinViewerProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const viewerRef = useRef<SkinViewer3D | null>(null);
|
||||
@@ -92,11 +94,19 @@ export default function SkinViewer({
|
||||
|
||||
// 设置背景和控制选项 - 参考blessingskin
|
||||
viewer.background = null; // 透明背景
|
||||
viewer.autoRotate = autoRotate && !walking && !running && !jumping; // 只在无动画时自动旋转
|
||||
viewer.autoRotate = false; // 完全禁用自动旋转
|
||||
|
||||
// 外部预览时禁用所有动画和旋转
|
||||
if (isExternalPreview) {
|
||||
viewer.autoRotate = false;
|
||||
viewer.controls.enableRotate = false; // 禁用旋转控制
|
||||
viewer.controls.enableZoom = false; // 禁用缩放
|
||||
} else {
|
||||
viewer.autoRotate = autoRotate && !walking && !running && !jumping;
|
||||
viewer.controls.enableRotate = rotation; // 根据参数控制旋转
|
||||
viewer.controls.enableZoom = true; // 启用缩放
|
||||
}
|
||||
|
||||
// 设置交互控制
|
||||
viewer.controls.enableRotate = rotation; // 根据参数控制旋转
|
||||
viewer.controls.enableZoom = true; // 启用缩放
|
||||
viewer.controls.enablePan = false; // 禁用平移
|
||||
|
||||
console.log('3D皮肤查看器初始化成功');
|
||||
@@ -118,7 +128,7 @@ export default function SkinViewer({
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [skinUrl, capeUrl, isSlim, width, height, autoRotate, walking, running, jumping, rotation, imageLoaded, hasError]);
|
||||
}, [skinUrl, capeUrl, isSlim, width, height, autoRotate, walking, running, jumping, rotation, imageLoaded, hasError, isExternalPreview]);
|
||||
|
||||
// 控制动画效果 - 参考 Blessing Skin 的实现
|
||||
useEffect(() => {
|
||||
@@ -126,29 +136,36 @@ export default function SkinViewer({
|
||||
|
||||
const viewer = viewerRef.current;
|
||||
|
||||
// 根据优先级设置动画 - 参考 Blessing Skin 的 animationFactories
|
||||
if (running) {
|
||||
// 跑步动画
|
||||
viewer.animation = new RunningAnimation();
|
||||
console.log('启用跑步动画');
|
||||
} else if (walking) {
|
||||
// 普通步行动画
|
||||
viewer.animation = new WalkingAnimation();
|
||||
console.log('启用步行动画');
|
||||
} else if (jumping) {
|
||||
// 飞行动画作为跳跃
|
||||
viewer.animation = new FlyingAnimation();
|
||||
console.log('启用跳跃动画');
|
||||
} else {
|
||||
// 静止动画
|
||||
// 外部预览时只使用静止动画,禁用所有其他动画
|
||||
if (isExternalPreview) {
|
||||
viewer.animation = new IdleAnimation();
|
||||
console.log('启用静止动画');
|
||||
viewer.autoRotate = false;
|
||||
console.log('外部预览模式:启用静止动画,禁用旋转');
|
||||
} else {
|
||||
// 根据优先级设置动画 - 参考 Blessing Skin 的 animationFactories
|
||||
if (running) {
|
||||
// 跑步动画
|
||||
viewer.animation = new RunningAnimation();
|
||||
console.log('启用跑步动画');
|
||||
} else if (walking) {
|
||||
// 普通步行动画
|
||||
viewer.animation = new WalkingAnimation();
|
||||
console.log('启用步行动画');
|
||||
} else if (jumping) {
|
||||
// 飞行动画作为跳跃
|
||||
viewer.animation = new FlyingAnimation();
|
||||
console.log('启用跳跃动画');
|
||||
} else {
|
||||
// 静止动画
|
||||
viewer.animation = new IdleAnimation();
|
||||
console.log('启用静止动画');
|
||||
}
|
||||
|
||||
// 更新自动旋转状态
|
||||
viewer.autoRotate = autoRotate && !walking && !running && !jumping;
|
||||
}
|
||||
|
||||
// 更新自动旋转状态
|
||||
viewer.autoRotate = autoRotate && !walking && !running && !jumping;
|
||||
|
||||
}, [walking, running, jumping, autoRotate]);
|
||||
}, [walking, running, jumping, autoRotate, isExternalPreview]);
|
||||
|
||||
// 当皮肤URL改变时更新
|
||||
useEffect(() => {
|
||||
|
||||
154
src/components/profile/CharacterCard.tsx
Normal file
154
src/components/profile/CharacterCard.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { UserIcon, PencilIcon, TrashIcon, CheckIcon } from '@heroicons/react/24/outline';
|
||||
import SkinViewer from '@/components/SkinViewer';
|
||||
import type { Profile } from '@/lib/api';
|
||||
|
||||
interface CharacterCardProps {
|
||||
profile: Profile;
|
||||
skinUrl?: string;
|
||||
isSlim?: boolean;
|
||||
isEditing?: boolean;
|
||||
editName?: string;
|
||||
onEdit: (uuid: string, currentName: string) => void;
|
||||
onSave: (uuid: string) => void;
|
||||
onCancel: () => void;
|
||||
onDelete: (uuid: string) => void;
|
||||
onSetActive: (uuid: string) => void;
|
||||
onSelectSkin: (uuid: string) => void;
|
||||
onEditNameChange: (name: string) => void;
|
||||
}
|
||||
|
||||
export default function CharacterCard({
|
||||
profile,
|
||||
skinUrl,
|
||||
isSlim,
|
||||
isEditing,
|
||||
editName,
|
||||
onEdit,
|
||||
onSave,
|
||||
onCancel,
|
||||
onDelete,
|
||||
onSetActive,
|
||||
onSelectSkin,
|
||||
onEditNameChange
|
||||
}: CharacterCardProps) {
|
||||
return (
|
||||
<motion.div
|
||||
key={profile.uuid}
|
||||
className="bg-white/50 dark:bg-gray-800/50 backdrop-blur-lg rounded-2xl p-6 border border-white/20 dark:border-gray-700/50 shadow-lg"
|
||||
whileHover={{ scale: 1.02, y: -5 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => onEditNameChange(e.target.value)}
|
||||
className="text-lg font-semibold bg-transparent border-b border-orange-500 focus:outline-none text-gray-900 dark:text-white flex-1 mr-2"
|
||||
onBlur={() => onSave(profile.uuid)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && onSave(profile.uuid)}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white truncate flex-1">{profile.name}</h3>
|
||||
)}
|
||||
{profile.is_active && (
|
||||
<span className="px-2 py-1 bg-gradient-to-r from-green-500 to-emerald-500 text-white text-xs rounded-full flex items-center space-x-1">
|
||||
<CheckIcon className="w-3 h-3" />
|
||||
<span>当前使用</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="aspect-square bg-gradient-to-br from-orange-100 to-amber-100 dark:from-gray-700 dark:to-gray-600 rounded-xl mb-4 flex items-center justify-center relative overflow-hidden">
|
||||
{skinUrl ? (
|
||||
<SkinViewer
|
||||
skinUrl={skinUrl}
|
||||
isSlim={isSlim}
|
||||
width={200}
|
||||
height={200}
|
||||
className="w-full h-full"
|
||||
autoRotate={false}
|
||||
/>
|
||||
) : (
|
||||
<motion.div
|
||||
className="w-20 h-20 bg-gradient-to-br from-orange-400 to-amber-500 rounded-lg shadow-lg flex items-center justify-center"
|
||||
whileHover={{ scale: 1.1, rotate: 5 }}
|
||||
transition={{ type: 'spring', stiffness: 300 }}
|
||||
>
|
||||
<UserIcon className="w-10 h-10 text-white" />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 皮肤选择按钮 */}
|
||||
<motion.button
|
||||
onClick={() => onSelectSkin(profile.uuid)}
|
||||
className="absolute bottom-2 right-2 bg-gradient-to-r from-orange-500 to-amber-500 text-white p-2 rounded-full shadow-lg"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
title="选择皮肤"
|
||||
>
|
||||
<PencilIcon className="w-4 h-4" />
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex gap-2">
|
||||
{!profile.is_active && (
|
||||
<motion.button
|
||||
onClick={() => onSetActive(profile.uuid)}
|
||||
className="flex-1 bg-gradient-to-r from-green-500 to-emerald-500 hover:from-green-600 hover:to-emerald-600 text-white text-sm py-2 px-3 rounded-lg transition-all duration-200"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
使用
|
||||
</motion.button>
|
||||
)}
|
||||
|
||||
{isEditing ? (
|
||||
<>
|
||||
<motion.button
|
||||
onClick={() => onSave(profile.uuid)}
|
||||
className="flex-1 bg-gradient-to-r from-green-500 to-emerald-500 hover:from-green-600 hover:to-emerald-600 text-white text-sm py-2 px-3 rounded-lg transition-all duration-200"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
保存
|
||||
</motion.button>
|
||||
<motion.button
|
||||
onClick={onCancel}
|
||||
className="flex-1 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 text-sm py-2 px-3 rounded-lg transition-all duration-200"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
取消
|
||||
</motion.button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<motion.button
|
||||
onClick={() => onEdit(profile.uuid, profile.name)}
|
||||
className="flex-1 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 text-sm py-2 px-3 rounded-lg transition-all duration-200"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<PencilIcon className="w-4 h-4 inline mr-1" />
|
||||
编辑
|
||||
</motion.button>
|
||||
<motion.button
|
||||
onClick={() => onDelete(profile.uuid)}
|
||||
className="px-3 py-2 border border-red-500 text-red-500 hover:bg-red-500 hover:text-white rounded-lg transition-all duration-200"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</motion.button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
76
src/components/profile/FavoritesTab.tsx
Normal file
76
src/components/profile/FavoritesTab.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { PhotoIcon, ArrowDownTrayIcon, EyeIcon, HeartIcon } from '@heroicons/react/24/outline';
|
||||
import { HeartIcon as HeartIconSolid } from '@heroicons/react/24/solid';
|
||||
import SkinCard from '@/components/SkinCard';
|
||||
import SkinDetailModal from '@/components/SkinDetailModal';
|
||||
import type { Texture } from '@/lib/api';
|
||||
|
||||
interface FavoritesTabProps {
|
||||
skins: Texture[];
|
||||
onToggleFavorite: (skinId: number) => void;
|
||||
}
|
||||
|
||||
export default function FavoritesTab({ skins, onToggleFavorite }: FavoritesTabProps) {
|
||||
const [selectedSkin, setSelectedSkin] = useState<Texture | null>(null);
|
||||
const [showSkinDetail, setShowSkinDetail] = useState(false);
|
||||
|
||||
const handleViewDetails = (skin: Texture) => {
|
||||
setSelectedSkin(skin);
|
||||
setShowSkinDetail(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">收藏夹</h1>
|
||||
</div>
|
||||
|
||||
{skins.length === 0 ? (
|
||||
<div className="bg-white/50 dark:bg-gray-800/50 backdrop-blur-lg rounded-2xl p-12 text-center">
|
||||
<HeartIcon className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">暂无收藏</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">去发现一些喜欢的皮肤吧!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{skins.map((skin) => (
|
||||
<SkinCard
|
||||
key={skin.id}
|
||||
texture={skin}
|
||||
isFavorited={true}
|
||||
onViewDetails={handleViewDetails}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
customActions={
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleFavorite(skin.id);
|
||||
}}
|
||||
className="flex-1 px-3 py-2 border border-red-500 text-red-500 hover:bg-red-500 hover:text-white rounded-lg transition-all duration-200"
|
||||
>
|
||||
取消收藏
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 皮肤详情模态框 */}
|
||||
<SkinDetailModal
|
||||
isOpen={showSkinDetail}
|
||||
onClose={() => setShowSkinDetail(false)}
|
||||
texture={selectedSkin}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
107
src/components/profile/MySkinsTab.tsx
Normal file
107
src/components/profile/MySkinsTab.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
PhotoIcon,
|
||||
ArrowDownTrayIcon,
|
||||
EyeIcon,
|
||||
TrashIcon,
|
||||
CloudArrowUpIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import SkinCard from '@/components/SkinCard';
|
||||
import SkinDetailModal from '@/components/SkinDetailModal';
|
||||
import type { Texture } from '@/lib/api';
|
||||
|
||||
interface MySkinsTabProps {
|
||||
skins: Texture[];
|
||||
onUploadClick: () => void;
|
||||
onToggleVisibility: (skinId: number) => void;
|
||||
onDelete: (skinId: number) => void;
|
||||
}
|
||||
|
||||
export default function MySkinsTab({
|
||||
skins,
|
||||
onUploadClick,
|
||||
onToggleVisibility,
|
||||
onDelete
|
||||
}: MySkinsTabProps) {
|
||||
const [selectedSkin, setSelectedSkin] = useState<Texture | null>(null);
|
||||
const [showSkinDetail, setShowSkinDetail] = useState(false);
|
||||
|
||||
const handleViewDetails = (skin: Texture) => {
|
||||
setSelectedSkin(skin);
|
||||
setShowSkinDetail(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">我的皮肤</h1>
|
||||
<motion.button
|
||||
onClick={onUploadClick}
|
||||
className="bg-gradient-to-r from-orange-500 to-amber-500 text-white px-4 py-2 rounded-xl flex items-center space-x-2 shadow-lg hover:shadow-xl transition-all duration-200"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<CloudArrowUpIcon className="w-5 h-5" />
|
||||
<span>上传皮肤</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
{skins.length === 0 ? (
|
||||
<div className="bg-white/50 dark:bg-gray-800/50 backdrop-blur-lg rounded-2xl p-12 text-center">
|
||||
<PhotoIcon className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">暂无皮肤</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">上传你的第一个Minecraft皮肤吧!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{skins.map((skin) => (
|
||||
<SkinCard
|
||||
key={skin.id}
|
||||
texture={skin}
|
||||
onViewDetails={handleViewDetails}
|
||||
onToggleVisibility={onToggleVisibility}
|
||||
customActions={
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleVisibility(skin.id);
|
||||
}}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-lg transition-all duration-200 text-sm"
|
||||
>
|
||||
{skin.is_public ? '隐藏' : '公开'}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(skin.id);
|
||||
}}
|
||||
className="px-3 py-2 border border-red-500 text-red-500 hover:bg-red-500 hover:text-white rounded-lg transition-all duration-200"
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 皮肤详情模态框 */}
|
||||
<SkinDetailModal
|
||||
isOpen={showSkinDetail}
|
||||
onClose={() => setShowSkinDetail(false)}
|
||||
texture={selectedSkin}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
79
src/components/profile/ProfileSidebar.tsx
Normal file
79
src/components/profile/ProfileSidebar.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
UserIcon,
|
||||
PhotoIcon,
|
||||
HeartIcon,
|
||||
Cog6ToothIcon,
|
||||
ArrowLeftOnRectangleIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
interface ProfileSidebarProps {
|
||||
activeTab: string;
|
||||
onTabChange: (tab: string) => void;
|
||||
skinCount: number;
|
||||
favoriteCount: number;
|
||||
profilesCount: number;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
export default function ProfileSidebar({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
skinCount,
|
||||
favoriteCount,
|
||||
profilesCount,
|
||||
onLogout
|
||||
}: ProfileSidebarProps) {
|
||||
const menuItems = [
|
||||
{ id: 'characters', name: '角色管理', icon: UserIcon, count: profilesCount },
|
||||
{ id: 'skins', name: '我的皮肤', icon: PhotoIcon, count: skinCount },
|
||||
{ id: 'favorites', name: '收藏夹', icon: HeartIcon, count: favoriteCount },
|
||||
{ id: 'settings', name: '账户设置', icon: Cog6ToothIcon },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex-1">
|
||||
<nav className="space-y-2">
|
||||
{menuItems.map((item) => (
|
||||
<motion.button
|
||||
key={item.id}
|
||||
onClick={() => onTabChange(item.id)}
|
||||
className={`w-full flex items-center justify-between p-3 rounded-xl transition-all duration-200 ${
|
||||
activeTab === item.id
|
||||
? 'bg-gradient-to-r from-orange-500 to-amber-500 text-white shadow-lg'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<item.icon className="w-5 h-5" />
|
||||
<span className="font-medium">{item.name}</span>
|
||||
</div>
|
||||
{item.count !== undefined && (
|
||||
<span className={`px-2 py-1 rounded-full text-xs ${
|
||||
activeTab === item.id
|
||||
? 'bg-white/20 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-600 text-gray-600 dark:text-gray-400'
|
||||
}`}>
|
||||
{item.count}
|
||||
</span>
|
||||
)}
|
||||
</motion.button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<motion.button
|
||||
onClick={onLogout}
|
||||
className="w-full flex items-center justify-center space-x-2 p-3 border border-red-500 text-red-500 hover:bg-red-500 hover:text-white rounded-xl transition-all duration-200 mt-6"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<ArrowLeftOnRectangleIcon className="w-5 h-5" />
|
||||
<span>退出登录</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
199
src/components/profile/UploadSkinModal.tsx
Normal file
199
src/components/profile/UploadSkinModal.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { XMarkIcon, CloudArrowUpIcon } from '@heroicons/react/24/outline';
|
||||
import { messageManager } from '@/components/MessageNotification';
|
||||
|
||||
interface UploadSkinModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onUpload: (file: File, data: {
|
||||
name: string;
|
||||
description: string;
|
||||
type: 'SKIN' | 'CAPE';
|
||||
is_public: boolean;
|
||||
is_slim: boolean;
|
||||
}) => Promise<void>;
|
||||
isUploading?: boolean;
|
||||
uploadProgress?: number;
|
||||
}
|
||||
|
||||
export default function UploadSkinModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onUpload,
|
||||
isUploading = false,
|
||||
uploadProgress = 0
|
||||
}: UploadSkinModalProps) {
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [skinData, setSkinData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
type: 'SKIN' as 'SKIN' | 'CAPE',
|
||||
is_public: false,
|
||||
is_slim: false
|
||||
});
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setSelectedFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!selectedFile || !skinData.name.trim()) {
|
||||
messageManager.warning('请选择皮肤文件并输入皮肤名称', { duration: 3000 });
|
||||
return;
|
||||
}
|
||||
|
||||
await onUpload(selectedFile, skinData);
|
||||
// 重置表单
|
||||
setSelectedFile(null);
|
||||
setSkinData({
|
||||
name: '',
|
||||
description: '',
|
||||
type: 'SKIN',
|
||||
is_public: false,
|
||||
is_slim: false
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-[9999]"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, y: 20 }}
|
||||
animate={{ scale: 1, y: 0 }}
|
||||
exit={{ scale: 0.9, y: 20 }}
|
||||
className="bg-white dark:bg-gray-800 rounded-2xl p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto"
|
||||
>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-xl font-bold text-gray-900 dark:text-white">上传皮肤</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<XMarkIcon className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
皮肤文件
|
||||
</label>
|
||||
<div className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl p-6 text-center hover:border-orange-500 transition-colors">
|
||||
<CloudArrowUpIcon className="w-12 h-12 text-gray-400 mx-auto mb-2" />
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
点击选择文件或拖拽到此处
|
||||
</p>
|
||||
<input
|
||||
type="file"
|
||||
accept=".png"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
id="skin-upload"
|
||||
/>
|
||||
<label
|
||||
htmlFor="skin-upload"
|
||||
className="cursor-pointer bg-gradient-to-r from-orange-500 to-amber-500 text-white px-4 py-2 rounded-lg hover:from-orange-600 hover:to-amber-600 transition-all"
|
||||
>
|
||||
选择文件
|
||||
</label>
|
||||
{selectedFile && (
|
||||
<p className="text-sm text-green-600 dark:text-green-400 mt-2">
|
||||
已选择: {selectedFile.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
皮肤名称
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={skinData.name}
|
||||
onChange={(e) => setSkinData(prev => ({ ...prev, name: e.target.value }))}
|
||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||
placeholder="请输入皮肤名称"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
描述
|
||||
</label>
|
||||
<textarea
|
||||
value={skinData.description}
|
||||
onChange={(e) => setSkinData(prev => ({ ...prev, description: e.target.value }))}
|
||||
className="w-full px-4 py-3 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||
placeholder="请输入皮肤描述(可选)"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={skinData.is_public}
|
||||
onChange={(e) => setSkinData(prev => ({ ...prev, is_public: e.target.checked }))}
|
||||
className="w-4 h-4 text-orange-500 rounded focus:ring-orange-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">公开</span>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={skinData.is_slim}
|
||||
onChange={(e) => setSkinData(prev => ({ ...prev, is_slim: e.target.checked }))}
|
||||
className="w-4 h-4 text-orange-500 rounded focus:ring-orange-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">细臂模型</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isUploading && (
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className="bg-gradient-to-r from-orange-500 to-amber-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!selectedFile || !skinData.name.trim() || isUploading}
|
||||
className="flex-1 px-4 py-2 bg-gradient-to-r from-orange-500 to-amber-500 text-white rounded-xl hover:from-orange-600 hover:to-amber-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||
>
|
||||
{isUploading ? `上传中... ${uploadProgress}%` : '上传'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
89
src/components/profile/UserProfileCard.tsx
Normal file
89
src/components/profile/UserProfileCard.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { UserCircleIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
interface UserProfileCardProps {
|
||||
username?: string;
|
||||
email?: string;
|
||||
avatar?: string;
|
||||
skinCount: number;
|
||||
favoriteCount: number;
|
||||
points: number;
|
||||
}
|
||||
|
||||
export default function UserProfileCard({
|
||||
username,
|
||||
email,
|
||||
avatar,
|
||||
skinCount,
|
||||
favoriteCount,
|
||||
points
|
||||
}: UserProfileCardProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ y: -20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.5, ease: "easeOut" }}
|
||||
className="bg-gradient-to-br from-orange-400 via-orange-500 to-amber-500 rounded-2xl p-6 mb-6 text-white shadow-xl relative overflow-hidden"
|
||||
>
|
||||
{/* 装饰性背景元素 */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-white/10 to-transparent rounded-2xl"></div>
|
||||
<div className="absolute top-0 right-0 w-20 h-20 bg-white/5 rounded-full -translate-y-8 translate-x-8"></div>
|
||||
<div className="absolute bottom-0 left-0 w-16 h-16 bg-white/5 rounded-full translate-y-6 -translate-x-6"></div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center space-x-4 mb-4">
|
||||
{avatar ? (
|
||||
<motion.img
|
||||
src={avatar}
|
||||
alt={username}
|
||||
className="w-16 h-16 rounded-full border-3 border-white/30 shadow-lg object-cover"
|
||||
whileHover={{ scale: 1.05, rotate: 5 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
) : (
|
||||
<motion.div
|
||||
className="w-16 h-16 bg-white/20 rounded-full flex items-center justify-center border-2 border-white/30"
|
||||
whileHover={{ scale: 1.05, rotate: 5 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<UserCircleIcon className="w-8 h-8 text-white" />
|
||||
</motion.div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-xl font-bold truncate">{username || '用户名'}</h2>
|
||||
<p className="text-white/80 text-sm truncate">{email || '邮箱地址'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<motion.div
|
||||
className="bg-white/10 rounded-lg p-3 text-center backdrop-blur-sm"
|
||||
whileHover={{ scale: 1.05, backgroundColor: 'rgba(255,255,255,0.15)' }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="text-2xl font-bold">{skinCount}</div>
|
||||
<div className="text-white/80 text-xs">皮肤</div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="bg-white/10 rounded-lg p-3 text-center backdrop-blur-sm"
|
||||
whileHover={{ scale: 1.05, backgroundColor: 'rgba(255,255,255,0.15)' }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="text-2xl font-bold">{favoriteCount}</div>
|
||||
<div className="text-white/80 text-xs">收藏</div>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
className="bg-white/10 rounded-lg p-3 text-center backdrop-blur-sm"
|
||||
whileHover={{ scale: 1.05, backgroundColor: 'rgba(255,255,255,0.15)' }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<div className="text-2xl font-bold">{points}</div>
|
||||
<div className="text-white/80 text-xs">积分</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user