Initial commit: CarrotSkin project setup

This commit is contained in:
2025-12-05 20:07:50 +08:00
parent a9ff72a9bf
commit f5e4c2a04b
24 changed files with 5389 additions and 2145 deletions

View File

@@ -8,6 +8,11 @@
--background: #ffffff; --background: #ffffff;
--foreground: #171717; --foreground: #171717;
--navbar-height: 64px; /* 与pt-16对应 */ --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) { @media (prefers-color-scheme: dark) {
@@ -21,6 +26,7 @@ body {
color: var(--foreground); color: var(--foreground);
background: var(--background); background: var(--background);
font-family: 'Inter', Arial, Helvetica, sans-serif; font-family: 'Inter', Arial, Helvetica, sans-serif;
scroll-behavior: smooth;
} }
/* Custom utility classes */ /* Custom utility classes */
@@ -28,34 +34,65 @@ body {
text-wrap: balance; text-wrap: balance;
} }
/* Custom component classes */ /* Enhanced Custom component classes with micro-interactions */
.btn-carrot { .btn-carrot {
background-color: #f97316; background-color: var(--primary-orange);
color: white; color: white;
font-weight: 500; font-weight: 500;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border-radius: 0.5rem; border-radius: 0.5rem;
transition: background-color 0.2s; transition: all var(--transition-normal);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); 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 { .btn-carrot:hover {
background-color: #ea580c; background-color: var(--primary-orange-dark);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1); 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 { .btn-carrot-outline {
border: 2px solid #f97316; border: 2px solid var(--primary-orange);
color: #f97316; color: var(--primary-orange);
font-weight: 500; font-weight: 500;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border-radius: 0.5rem; border-radius: 0.5rem;
transition: all 0.2s; transition: all var(--transition-normal);
position: relative;
overflow: hidden;
} }
.btn-carrot-outline:hover { .btn-carrot-outline:hover {
background-color: #f97316; background-color: var(--primary-orange);
color: white; 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 { .card-minecraft {
@@ -63,11 +100,31 @@ body {
border: 2px solid #fed7aa; border: 2px solid #fed7aa;
border-radius: 0.5rem; border-radius: 0.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); 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 { .card-minecraft:hover {
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1); 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) { @media (prefers-color-scheme: dark) {
@@ -75,6 +132,10 @@ body {
background-color: #1f2937; background-color: #1f2937;
border-color: #c2410c; border-color: #c2410c;
} }
.card-minecraft:hover {
border-color: var(--primary-orange);
}
} }
.text-gradient { .text-gradient {
@@ -82,10 +143,18 @@ body {
background-clip: text; background-clip: text;
-webkit-background-clip: text; -webkit-background-clip: text;
color: transparent; 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 { .bg-gradient-carrot {
background: linear-gradient(to bottom right, #fb923c, #f97316, #ea580c); background: linear-gradient(to bottom right, #fb923c, #f97316, #ea580c);
transition: all var(--transition-normal);
} }
/* 现代布局解决方案 */ /* 现代布局解决方案 */
@@ -104,4 +173,277 @@ body {
.min-h-screen-nav { .min-h-screen-nav {
min-height: calc(100vh - var(--navbar-height)); 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);
}
} }

View File

@@ -4,8 +4,10 @@ import "./globals.css";
import Navbar from "@/components/Navbar"; import Navbar from "@/components/Navbar";
import { AuthProvider } from "@/contexts/AuthContext"; import { AuthProvider } from "@/contexts/AuthContext";
import { MainContent } from "@/components/MainContent"; import { MainContent } from "@/components/MainContent";
import { MessageNotificationContainer } from "@/components/MessageNotification";
import { ErrorNotificationContainer } from "@/components/ErrorNotification"; import { ErrorNotificationContainer } from "@/components/ErrorNotification";
import ScrollToTop from "@/components/ScrollToTop"; import ScrollToTop from "@/components/ScrollToTop";
import PageTransition from "@/components/PageTransition";
const inter = Inter({ const inter = Inter({
subsets: ["latin"], subsets: ["latin"],
@@ -35,8 +37,11 @@ export default function RootLayout({
<body className={inter.className}> <body className={inter.className}>
<AuthProvider> <AuthProvider>
<Navbar /> <Navbar />
<PageTransition>
<MainContent>{children}</MainContent> <MainContent>{children}</MainContent>
</PageTransition>
<ErrorNotificationContainer /> <ErrorNotificationContainer />
<MessageNotificationContainer />
<ScrollToTop /> <ScrollToTop />
</AuthProvider> </AuthProvider>
</body> </body>

View File

@@ -1,108 +1,7 @@
'use client'; 'use client';
import Link from 'next/link'; import { NotFoundPage } from '@/components/ErrorPage';
import { motion } from 'framer-motion';
import { HomeIcon, ArrowLeftIcon } from '@heroicons/react/24/outline';
export default function NotFound() { export default function NotFound() {
return ( return <NotFoundPage />;
<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>
);
} }

File diff suppressed because it is too large Load Diff

View File

@@ -6,8 +6,10 @@ import { MagnifyingGlassIcon, EyeIcon, HeartIcon, ArrowDownTrayIcon, SparklesIco
import { HeartIcon as HeartIconSolid } from '@heroicons/react/24/solid'; import { HeartIcon as HeartIconSolid } from '@heroicons/react/24/solid';
import SkinViewer from '@/components/SkinViewer'; import SkinViewer from '@/components/SkinViewer';
import SkinDetailModal from '@/components/SkinDetailModal'; import SkinDetailModal from '@/components/SkinDetailModal';
import SkinCard from '@/components/SkinCard';
import { searchTextures, toggleFavorite, type Texture } from '@/lib/api'; import { searchTextures, toggleFavorite, type Texture } from '@/lib/api';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { messageManager } from '@/components/MessageNotification';
export default function SkinsPage() { export default function SkinsPage() {
const [textures, setTextures] = useState<Texture[]>([]); const [textures, setTextures] = useState<Texture[]>([]);
@@ -106,7 +108,7 @@ export default function SkinsPage() {
// 处理收藏 // 处理收藏
const handleFavorite = async (textureId: number) => { const handleFavorite = async (textureId: number) => {
if (!isAuthenticated) { if (!isAuthenticated) {
alert('请先登录'); messageManager.warning('请先登录', { duration: 3000 });
return; return;
} }
@@ -309,130 +311,15 @@ export default function SkinsPage() {
const isFavorited = favoritedIds.has(texture.id); const isFavorited = favoritedIds.has(texture.id);
return ( return (
<motion.div <SkinCard
key={texture.id} key={texture.id}
initial={{ opacity: 0, y: 20 }} texture={texture}
animate={{ opacity: 1, y: 0 }} isFavorited={isFavorited}
transition={{ delay: index * 0.1 }} onViewDetails={handleDetailView}
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" onToggleFavorite={isAuthenticated ? handleFavorite : undefined}
> onDownload={(texture) => window.open(texture.url, '_blank')}
{/* 3D预览区域 - 更紧凑 */} showVisibilityBadge={false}
<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>
); );
})} })}
</motion.div> </motion.div>

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

View 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;

View File

@@ -15,16 +15,26 @@ interface ErrorNotificationProps {
export function ErrorNotification({ message, type = 'error', duration = 5000, onClose }: ErrorNotificationProps) { export function ErrorNotification({ message, type = 'error', duration = 5000, onClose }: ErrorNotificationProps) {
const [isVisible, setIsVisible] = useState(true); const [isVisible, setIsVisible] = useState(true);
const [isHovered, setIsHovered] = useState(false);
const [progress, setProgress] = useState(100);
useEffect(() => { useEffect(() => {
if (duration > 0) { if (duration > 0 && !isHovered) {
const timer = setTimeout(() => { 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); setIsVisible(false);
onClose?.(); onClose?.();
}, duration);
return () => clearTimeout(timer);
} }
}, [duration, onClose]); }, 50);
return () => clearInterval(timer);
}
}, [duration, onClose, isHovered]);
const handleClose = () => { const handleClose = () => {
setIsVisible(false); setIsVisible(false);
@@ -52,7 +62,8 @@ export function ErrorNotification({ message, type = 'error', duration = 5000, on
border: 'border-red-200 dark:border-red-800', border: 'border-red-200 dark:border-red-800',
text: 'text-red-800 dark:text-red-200', text: 'text-red-800 dark:text-red-200',
icon: 'text-red-500', 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': case 'warning':
return { return {
@@ -60,7 +71,8 @@ export function ErrorNotification({ message, type = 'error', duration = 5000, on
border: 'border-yellow-200 dark:border-yellow-800', border: 'border-yellow-200 dark:border-yellow-800',
text: 'text-yellow-800 dark:text-yellow-200', text: 'text-yellow-800 dark:text-yellow-200',
icon: 'text-yellow-500', 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': case 'success':
return { return {
@@ -68,7 +80,8 @@ export function ErrorNotification({ message, type = 'error', duration = 5000, on
border: 'border-green-200 dark:border-green-800', border: 'border-green-200 dark:border-green-800',
text: 'text-green-800 dark:text-green-200', text: 'text-green-800 dark:text-green-200',
icon: 'text-green-500', 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': case 'info':
return { return {
@@ -76,38 +89,98 @@ export function ErrorNotification({ message, type = 'error', duration = 5000, on
border: 'border-blue-200 dark:border-blue-800', border: 'border-blue-200 dark:border-blue-800',
text: 'text-blue-800 dark:text-blue-200', text: 'text-blue-800 dark:text-blue-200',
icon: 'text-blue-500', 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 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 ( return (
<AnimatePresence> <AnimatePresence>
{isVisible && ( {isVisible && (
<motion.div <motion.div
initial={{ opacity: 0, y: -20, scale: 0.9 }} {...getAnimationVariants()}
animate={{ opacity: 1, y: 0, scale: 1 }} transition={{
exit={{ opacity: 0, y: -20, scale: 0.9 }} duration: 0.4,
transition={{ duration: 0.3, ease: 'easeOut' }} 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`} 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 }}
> >
<div className="flex items-start p-4"> {/* Progress bar */}
<div className={`flex-shrink-0 ${styles.icon} mr-3 mt-0.5`}> <div className="absolute bottom-0 left-0 h-1 bg-gray-200/50 dark:bg-gray-700/50 w-full">
{getIcon()} <motion.div
className={`h-full ${styles.progress}`}
initial={{ width: '100%' }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.1, ease: 'linear' }}
/>
</div> </div>
<div className="flex items-start p-4">
<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()}
</motion.div>
<div className="flex-1"> <div className="flex-1">
<p className={`text-sm font-medium ${styles.text}`}> <p className={`text-sm font-medium ${styles.text} leading-relaxed`}>
{message} {message}
</p> </p>
</div> </div>
<button <motion.button
onClick={handleClose} 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" /> <XMarkIcon className="w-5 h-5" />
</button> </motion.button>
</div> </div>
</motion.div> </motion.div>
)} )}
@@ -115,10 +188,11 @@ export function ErrorNotification({ message, type = 'error', duration = 5000, on
); );
} }
// 全局错误管理器 // 增强的全局错误管理器
class ErrorManager { class ErrorManager {
private static instance: ErrorManager; private static instance: ErrorManager;
private listeners: Array<(notification: ErrorNotificationProps & { id: string }) => void> = []; private listeners: Array<(notification: ErrorNotificationProps & { id: string }) => void> = [];
private soundEnabled: boolean = true;
static getInstance(): ErrorManager { static getInstance(): ErrorManager {
if (!ErrorManager.instance) { if (!ErrorManager.instance) {
@@ -127,19 +201,51 @@ class ErrorManager {
return ErrorManager.instance; 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) { showError(message: string, duration?: number) {
this.playSound('error');
this.showNotification(message, 'error', duration); this.showNotification(message, 'error', duration);
} }
showWarning(message: string, duration?: number) { showWarning(message: string, duration?: number) {
this.playSound('warning');
this.showNotification(message, 'warning', duration); this.showNotification(message, 'warning', duration);
} }
showSuccess(message: string, duration?: number) { showSuccess(message: string, duration?: number) {
this.playSound('success');
this.showNotification(message, 'success', duration); this.showNotification(message, 'success', duration);
} }
showInfo(message: string, duration?: number) { showInfo(message: string, duration?: number) {
this.playSound('info');
this.showNotification(message, 'info', duration); this.showNotification(message, 'info', duration);
} }
@@ -160,13 +266,18 @@ class ErrorManager {
this.listeners = this.listeners.filter(l => l !== listener); this.listeners = this.listeners.filter(l => l !== listener);
}; };
} }
setSoundEnabled(enabled: boolean) {
this.soundEnabled = enabled;
}
} }
export const errorManager = ErrorManager.getInstance(); export const errorManager = ErrorManager.getInstance();
// 错误提示容器组件 // 增强的错误提示容器组件
export function ErrorNotificationContainer() { export function ErrorNotificationContainer() {
const [notifications, setNotifications] = useState<Array<ErrorNotificationProps & { id: string }>>([]); const [notifications, setNotifications] = useState<Array<ErrorNotificationProps & { id: string }>>([]);
const [soundEnabled, setSoundEnabled] = useState(true);
useEffect(() => { useEffect(() => {
const unsubscribe = errorManager.subscribe((notification) => { const unsubscribe = errorManager.subscribe((notification) => {
@@ -176,19 +287,30 @@ export function ErrorNotificationContainer() {
return unsubscribe; return unsubscribe;
}, []); }, []);
useEffect(() => {
errorManager.setSoundEnabled(soundEnabled);
}, [soundEnabled]);
const removeNotification = (id: string) => { const removeNotification = (id: string) => {
setNotifications(prev => prev.filter(n => n.id !== id)); setNotifications(prev => prev.filter(n => n.id !== id));
}; };
return ( return (
<> <>
{notifications.map((notification) => ( {notifications.map((notification, index) => (
<ErrorNotification <motion.div
key={notification.id} key={notification.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
>
<ErrorNotification
{...notification} {...notification}
onClose={() => removeNotification(notification.id)} onClose={() => removeNotification(notification.id)}
/> />
</motion.div>
))} ))}
</> </>
); );
} }

View File

@@ -1,7 +1,8 @@
'use client'; 'use client';
import Link from 'next/link'; 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 { import {
HomeIcon, HomeIcon,
ArrowLeftIcon, ArrowLeftIcon,
@@ -9,8 +10,15 @@ import {
XCircleIcon, XCircleIcon,
ClockIcon, ClockIcon,
ServerIcon, ServerIcon,
WifiIcon WifiIcon,
ClipboardDocumentIcon,
ArrowPathIcon,
CubeIcon,
QuestionMarkCircleIcon,
SparklesIcon,
RocketLaunchIcon
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { messageManager } from './MessageNotification';
export interface ErrorPageProps { export interface ErrorPageProps {
code?: number; code?: number;
@@ -31,47 +39,122 @@ export interface ErrorPageProps {
}; };
}; };
showContact?: boolean; showContact?: boolean;
showRetry?: boolean;
onRetry?: () => void;
showCopyError?: boolean;
errorDetails?: string;
className?: string;
} }
const errorConfigs = { const errorConfigs = {
'404': { '404': {
icon: <XCircleIcon className="w-16 h-16" />, icon: <CubeIcon className="w-20 h-20" />,
title: '页面不见了', title: '页面未找到',
message: '抱歉,我们找不到您要访问的页面。', message: '这个页面似乎不存在于我们的世界中',
description: '可能已被移动、删除,或者您输入的链接不正确。' description: '页面可能已被移除、重命名,或者您输入的地址不正确。',
suggestions: [
'检查网址拼写是否正确',
'返回主页重新探索',
'使用搜索功能寻找内容'
]
}, },
'500': { '500': {
icon: <ServerIcon className="w-16 h-16" />, icon: <ServerIcon className="w-20 h-20" />,
title: '服务器错误', title: '服务器错误',
message: '抱歉,服务器遇到了一些问题', message: '我们的服务器遇到了一些技术问题',
description: '我们的团队正在努力解决这个问题,请稍后再试。' description: '工程师们正在紧急修复中,请稍后再试。',
suggestions: [
'稍后刷新页面重试',
'清除浏览器缓存',
'检查网络连接'
]
}, },
'403': { '403': {
icon: <ExclamationTriangleIcon className="w-16 h-16" />, icon: <ExclamationTriangleIcon className="w-20 h-20" />,
title: '访问被拒绝', title: '访问被拒绝',
message: '抱歉,您没有权限访问此页面。', message: '您没有权限进入这个区域',
description: '请检查您的账户权限或联系管理员。' description: '请检查您的权限等级或联系管理员获取访问权限。',
suggestions: [
'确认您是否已登录',
'检查账户权限等级',
'联系管理员申请权限'
]
}, },
'network': { network: {
icon: <WifiIcon className="w-16 h-16" />, icon: <WifiIcon className="w-20 h-20" />,
title: '网络连接错误', title: '网络连接问题',
message: '无法连接到服务器。', message: '与我们的连接出现了问题',
description: '请检查您的网络连接,然后重试。' description: '请检查您的网络连接,然后重新尝试。',
suggestions: [
'检查网络连接状态',
'尝试重新连接',
'检查防火墙设置'
]
}, },
'timeout': { timeout: {
icon: <ClockIcon className="w-16 h-16" />, icon: <ClockIcon className="w-20 h-20" />,
title: '请求超时', title: '连接超时',
message: '请求处理时间过长', message: '服务器响应时间过长',
description: '请刷新页面或稍后再试。' description: '服务器响应缓慢,请稍后再试。',
suggestions: [
'检查网络连接状态',
'稍后重新尝试连接',
'联系技术支持团队'
]
}, },
'maintenance': { maintenance: {
icon: <ServerIcon className="w-16 h-16" />, icon: <ServerIcon className="w-20 h-20" />,
title: '系统维护中', title: '系统维护中',
message: '我们正在进行系统维护。', message: '我们正在对系统进行升级改造',
description: '请稍后再试,我们会尽快恢复服务。' 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({ export function ErrorPage({
code, code,
title, title,
@@ -79,176 +162,372 @@ export function ErrorPage({
description, description,
type = 'custom', type = 'custom',
actions, actions,
showContact = true showContact = true,
showRetry = true,
onRetry,
showCopyError = true,
errorDetails,
className = ''
}: ErrorPageProps) { }: 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 config = errorConfigs[type] || {};
const displayTitle = title || config.title || '出错了'; const displayTitle = title || config.title || '出错了';
const displayMessage = message || config.message || '发生了一些错误'; const displayMessage = message || config.message || '发生了一些错误';
const displayDescription = description || config.description || ''; 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 = { const defaultActions = {
primary: { primary: {
label: '返回主', label: '返回主',
href: '/' href: '/'
}, },
secondary: { secondary: {
label: '返回上页', label: '返回上页',
onClick: () => window.history.back() onClick: () => {
if (typeof window !== 'undefined') {
window.history.back();
}
}
} }
}; };
const finalActions = { ...defaultActions, ...actions }; 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 = () => { const getIconColor = () => {
switch (type) { const colors = {
case '404': return 'text-orange-500'; '404': 'text-orange-500',
case '500': return 'text-red-500'; '500': 'text-red-500',
case '403': return 'text-yellow-500'; '403': 'text-yellow-500',
case 'network': return 'text-blue-500'; 'network': 'text-blue-500',
case 'timeout': return 'text-purple-500'; 'timeout': 'text-purple-500',
case 'maintenance': return 'text-gray-500'; 'maintenance': 'text-gray-500',
default: return 'text-orange-500'; 'custom': 'text-gray-500'
} };
return colors[type] || 'text-gray-500';
}; };
const getCodeColor = () => { const getCodeColor = () => {
switch (type) { const colors = {
case '404': return 'from-orange-400 via-orange-500 to-amber-500'; '404': 'from-orange-400 via-orange-500 to-amber-500',
case '500': return 'from-red-400 via-red-500 to-pink-500'; '500': 'from-red-400 via-red-500 to-pink-500',
case '403': return 'from-yellow-400 via-yellow-500 to-orange-500'; '403': 'from-yellow-400 via-yellow-500 to-orange-500',
case 'network': return 'from-blue-400 via-blue-500 to-cyan-500'; 'network': 'from-blue-400 via-blue-500 to-cyan-500',
case 'timeout': return 'from-purple-400 via-purple-500 to-pink-500'; 'timeout': 'from-purple-400 via-purple-500 to-pink-500',
case 'maintenance': return 'from-gray-400 via-gray-500 to-slate-500'; 'maintenance': 'from-gray-400 via-gray-500 to-slate-500',
default: return 'from-orange-400 via-orange-500 to-amber-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 ( 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"> <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 <motion.div
className="text-center px-4 max-w-2xl mx-auto" 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"
initial={{ opacity: 0, y: 20 }} animate={{
animate={{ opacity: 1, y: 0 }} scale: [1, 1.2, 1],
transition={{ duration: 0.8, ease: 'easeOut' }} opacity: [0.3, 0.5, 0.3]
}}
transition={{ duration: 4, repeat: Infinity }}
/>
<motion.div
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 && ( {code && (
<motion.div <motion.div
className="mb-8"
initial={{ scale: 0.5, opacity: 0 }} initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }} animate={{ scale: 1, opacity: 1 }}
transition={{ delay: 0.2, duration: 0.6, type: 'spring', stiffness: 200 }} transition={{ duration: 0.8, type: 'spring', stiffness: 100 }}
className="mb-12"
> >
<h1 className={`text-9xl font-black bg-gradient-to-r ${getCodeColor()} bg-clip-text text-transparent mb-4`}> <h1 className={`text-9xl md:text-[12rem] font-black bg-gradient-to-r ${getCodeColor()} bg-clip-text text-transparent mb-2`}>
{code} {code}
</h1> </h1>
<div className={`w-24 h-1 bg-gradient-to-r ${getCodeColor()} mx-auto rounded-full`} /> <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> </motion.div>
)} )}
{/* 图标 */} {/* Main Error Message - 简洁有力的错误信息 */}
<motion.div <motion.div
className="mb-8 flex justify-center" className="mb-16 space-y-6"
initial={{ opacity: 0, scale: 0.8 }} initial={{ opacity: 0, y: 30 }}
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>
{/* 错误信息 */}
<motion.div
className="mb-8"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6, duration: 0.6 }} transition={{ delay: 0.3, duration: 0.8 }}
> >
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-4"> <div className="space-y-4">
<h2 className="text-4xl md:text-6xl font-bold text-gray-900 dark:text-white leading-tight">
{displayTitle} {displayTitle}
</h2> </h2>
<p className="text-xl text-gray-700 dark:text-gray-300 mb-2"> <p className="text-xl md:text-2xl text-gray-600 dark:text-gray-300 leading-relaxed max-w-2xl mx-auto">
{displayMessage} {displayMessage}
</p> </p>
{displayDescription && ( {displayDescription && (
<p className="text-lg text-gray-600 dark:text-gray-400 leading-relaxed"> <p className="text-lg text-gray-500 dark:text-gray-400 leading-relaxed max-w-xl mx-auto">
{displayDescription} {displayDescription}
</p> </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> </motion.div>
{/* 操作按钮 */} {/* Action Buttons - 更简洁的按钮布局 */}
<motion.div <motion.div
className="flex flex-col sm:flex-row gap-4 justify-center items-center" className="mb-12"
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.8, duration: 0.6 }} 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 && ( {finalActions.primary && (
finalActions.primary.href ? ( <ActionButton action={finalActions.primary} colorClass={getButtonColor()} primary />
<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 && (
finalActions.secondary.href ? ( <ActionButton action={finalActions.secondary} />
<Link )}
href={finalActions.secondary.href} </div>
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"
{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 }}
> >
<ArrowLeftIcon className="w-5 h-5 mr-2" /> <ArrowPathIcon className={`w-5 h-5 mr-2 ${isRetrying ? 'animate-spin' : ''}`} />
{finalActions.secondary.label} {isRetrying ? '重试中...' : '重新加载'}
</Link> </motion.button>
) : (
<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> </motion.div>
{/* 联系信息 */} {/* Suggestions - 更简洁的建议展示 */}
{showContact && ( {config.suggestions && config.suggestions.length > 0 && (
<motion.div <motion.div
className="mt-8 text-sm text-gray-500 dark:text-gray-400" className="mb-12"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ delay: 1, duration: 0.6 }} transition={{ delay: 1, duration: 0.6 }}
> >
<p> <h3 className="text-lg font-semibold text-gray-700 dark:text-gray-300 mb-4">
<Link href="/contact" className="text-orange-500 hover:text-orange-600 underline mx-1">
</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> </Link>
</p> </p>
</motion.div> </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>
</motion.div>
</div> </div>
); );
} }
@@ -277,3 +556,127 @@ export function TimeoutErrorPage() {
export function MaintenancePage() { export function MaintenancePage() {
return <ErrorPage type="maintenance" />; 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 />;
}
```
*/

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

View File

@@ -6,11 +6,12 @@ export function MainContent({ children }: { children: React.ReactNode }) {
const pathname = usePathname(); const pathname = usePathname();
const isAuthPage = pathname === '/auth'; const isAuthPage = pathname === '/auth';
const isHomePage = pathname === '/'; const isHomePage = pathname === '/';
const isErrorPage = pathname === '/404' || pathname === '/500' || pathname === '/403' || pathname === '/error';
return ( return (
<main className={` <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 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} {children}
</main> </main>

View 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';

View File

@@ -4,7 +4,7 @@ import { useState, useEffect, useRef } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter, usePathname } from 'next/navigation'; import { useRouter, usePathname } from 'next/navigation';
import { Bars3Icon, XMarkIcon, UserCircleIcon } from '@heroicons/react/24/outline'; 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'; import { useAuth } from '@/contexts/AuthContext';
export default function Navbar() { export default function Navbar() {
@@ -13,10 +13,18 @@ export default function Navbar() {
const [isScrolled, setIsScrolled] = useState(false); const [isScrolled, setIsScrolled] = useState(false);
const [showScrollTop, setShowScrollTop] = useState(false); const [showScrollTop, setShowScrollTop] = useState(false);
const [navbarHeight, setNavbarHeight] = useState(0); const [navbarHeight, setNavbarHeight] = useState(0);
const [scrollProgress, setScrollProgress] = useState(0);
const navbarRef = useRef<HTMLElement>(null); const navbarRef = useRef<HTMLElement>(null);
const { user, isAuthenticated, logout } = useAuth(); const { user, isAuthenticated, logout } = useAuth();
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); 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 // 在auth页面隐藏navbar
const isAuthPage = pathname === '/auth'; const isAuthPage = pathname === '/auth';
@@ -36,6 +44,19 @@ export default function Navbar() {
return () => window.removeEventListener('resize', updateHeight); 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(() => { useEffect(() => {
let lastScrollY = 0; let lastScrollY = 0;
let ticking = false; let ticking = false;
@@ -94,15 +115,34 @@ export default function Navbar() {
}); });
}; };
const navItems = [
{ href: '/', label: '首页', icon: null },
{ href: '/skins', label: '皮肤库', icon: null },
];
return ( return (
<>
<motion.nav <motion.nav
ref={navbarRef} ref={navbarRef}
style={{
y: isHidden ? navbarY : 0,
opacity: navbarOpacity
}}
initial={{ y: 0 }} initial={{ y: 0 }}
animate={{ y: isHidden ? -100 : 0 }} animate={{ y: isHidden ? -100 : 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }} 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" 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' }} 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={` <div className={`
max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8
${isScrolled ? 'py-3' : 'py-4'} ${isScrolled ? 'py-3' : 'py-4'}
@@ -117,11 +157,17 @@ export default function Navbar() {
> >
<Link href="/" className="flex items-center space-x-3 group"> <Link href="/" className="flex items-center space-x-3 group">
<motion.div <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" 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 }} whileHover={{ rotate: 5, scale: 1.05 }}
transition={{ type: 'spring', stiffness: 300 }} transition={{ type: 'spring', stiffness: 300 }}
> >
<span className="text-white font-bold text-lg">C</span> <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.div>
<motion.span <motion.span
className="text-2xl font-black bg-gradient-to-r from-orange-400 to-orange-600 bg-clip-text text-transparent" className="text-2xl font-black bg-gradient-to-r from-orange-400 to-orange-600 bg-clip-text text-transparent"
@@ -134,41 +180,48 @@ export default function Navbar() {
{/* Desktop Navigation */} {/* Desktop Navigation */}
<div className="hidden md:flex items-center space-x-6"> <div className="hidden md:flex items-center space-x-6">
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}> {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 }}
>
<Link <Link
href="/" 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" 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"
> >
{item.label}
<motion.span <motion.span
className="absolute -bottom-0.5 left-3 right-3 h-0.5 bg-gradient-to-r from-orange-400 to-orange-600" 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 }} initial={{ scaleX: 0 }}
whileHover={{ scaleX: 1 }} whileHover={{ scaleX: 1 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
/> />
</Link> {pathname === item.href && (
</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 <motion.span
className="absolute -bottom-0.5 left-3 right-3 h-0.5 bg-gradient-to-r from-orange-400 to-orange-600" 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 }} initial={{ scaleX: 0 }}
whileHover={{ scaleX: 1 }} animate={{ scaleX: 1 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
/> />
)}
</Link> </Link>
</motion.div> </motion.div>
))}
{/* 用户头像框 - 增强的微交互 */}
{/* 用户头像框 - 类似知乎和哔哩哔哩的设计 */}
{isAuthenticated ? ( {isAuthenticated ? (
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}> <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 <Link
href="/profile" href="/profile"
className="flex items-center space-x-3 group" className="flex items-center space-x-3 group"
@@ -177,7 +230,7 @@ export default function Navbar() {
{user?.avatar ? ( {user?.avatar ? (
<motion.div <motion.div
className="relative" className="relative"
whileHover={{ scale: 1.1 }} whileHover={{ scale: 1.1, rotate: 5 }}
> >
<img <img
src={user.avatar} src={user.avatar}
@@ -190,11 +243,17 @@ export default function Navbar() {
whileHover={{ opacity: 1 }} whileHover={{ opacity: 1 }}
transition={{ duration: 0.2 }} 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>
) : ( ) : (
<motion.div <motion.div
className="relative" className="relative"
whileHover={{ scale: 1.1 }} whileHover={{ scale: 1.1, rotate: 5 }}
> >
<UserCircleIcon className="w-9 h-9 text-gray-400 group-hover:text-orange-500 transition-all duration-200" /> <UserCircleIcon className="w-9 h-9 text-gray-400 group-hover:text-orange-500 transition-all duration-200" />
<motion.div <motion.div
@@ -219,6 +278,9 @@ export default function Navbar() {
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" 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 }} whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
initial={{ opacity: 0, x: 10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.3 }}
> >
<motion.span <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" className="absolute inset-0 w-0 bg-gradient-to-r from-orange-400 to-orange-600 transition-all duration-300 group-hover:w-full"
@@ -229,14 +291,20 @@ export default function Navbar() {
</motion.button> </motion.button>
</div> </div>
) : ( ) : (
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}> <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 <Link
href="/auth" 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" 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 <motion.div
className="relative" className="relative"
whileHover={{ scale: 1.1 }} whileHover={{ scale: 1.1, rotate: 10 }}
> >
<UserCircleIcon className="w-7 h-7 text-gray-400 group-hover:text-orange-500 transition-all duration-200" /> <UserCircleIcon className="w-7 h-7 text-gray-400 group-hover:text-orange-500 transition-all duration-200" />
<motion.div <motion.div
@@ -259,8 +327,32 @@ export default function Navbar() {
className="text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 transition-colors duration-200 p-2" 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 }} whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }} whileTap={{ scale: 0.9 }}
animate={{ rotate: isOpen ? 180 : 0 }}
transition={{ duration: 0.3 }}
> >
{isOpen ? <XMarkIcon className="w-6 h-6" /> : <Bars3Icon className="w-6 h-6" />} <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> </motion.button>
</div> </div>
</div> </div>
@@ -270,42 +362,29 @@ export default function Navbar() {
<AnimatePresence> <AnimatePresence>
{isOpen && ( {isOpen && (
<motion.div <motion.div
initial={{ opacity: 0, y: -20 }} initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, y: -20 }} exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2, ease: 'easeInOut' }} 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" 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"> <div className="px-4 py-4 space-y-1">
{navItems.map((item, index) => (
<motion.div <motion.div
key={item.href}
initial={{ opacity: 0, x: -20 }} initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }} animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.05 }} transition={{ delay: index * 0.05 }}
> >
<Link <Link
href="/" 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" 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} onClick={handleLinkClick}
> >
{item.label}
</Link> </Link>
</motion.div> </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>
{isAuthenticated ? ( {isAuthenticated ? (
<> <>
@@ -383,5 +462,30 @@ export default function Navbar() {
)} )}
</AnimatePresence> </AnimatePresence>
</motion.nav> </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>
</>
); );
} }

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

View File

@@ -41,7 +41,7 @@ export default function ScrollToTop() {
exit={{ opacity: 0, scale: 0.8, y: 20 }} exit={{ opacity: 0, scale: 0.8, y: 20 }}
transition={{ duration: 0.2, ease: 'easeOut' }} transition={{ duration: 0.2, ease: 'easeOut' }}
onClick={scrollToTop} 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 }} whileHover={{ scale: 1.1, y: -2 }}
whileTap={{ scale: 0.9 }} whileTap={{ scale: 0.9 }}
> >

446
src/components/SkinCard.tsx Normal file
View 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>
);
}

View File

@@ -1,8 +1,8 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence, useSpring, useTransform } from 'framer-motion';
import { XMarkIcon, PlayIcon, PauseIcon, ArrowPathIcon, ForwardIcon} from '@heroicons/react/24/outline'; import { XMarkIcon, PlayIcon, PauseIcon, ArrowPathIcon, ForwardIcon } from '@heroicons/react/24/outline';
import SkinViewer from './SkinViewer'; import SkinViewer from './SkinViewer';
interface SkinDetailModalProps { interface SkinDetailModalProps {
@@ -22,13 +22,21 @@ interface SkinDetailModalProps {
username: string; username: string;
}; };
} | null; } | 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 [isPlaying, setIsPlaying] = useState(false);
const [currentAnimation, setCurrentAnimation] = useState<'idle' | 'walking' | 'running' | 'jumping'>('idle'); const [currentAnimation, setCurrentAnimation] = useState<'idle' | 'walking' | 'running' | 'swimming'>('idle');
const [autoRotate, setAutoRotate] = useState(true); const [autoRotate, setAutoRotate] = useState(!isExternalPreview);
const [rotation, setRotation] = useState(true); 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(() => { useEffect(() => {
@@ -37,6 +45,8 @@ export default function SkinDetailModal({ isOpen, onClose, texture }: SkinDetail
setCurrentAnimation('idle'); setCurrentAnimation('idle');
setAutoRotate(true); setAutoRotate(true);
setRotation(true); setRotation(true);
setIsMinimized(false);
setActiveTab('preview');
} }
}, [isOpen]); }, [isOpen]);
@@ -63,14 +73,101 @@ export default function SkinDetailModal({ isOpen, onClose, texture }: SkinDetail
setCurrentAnimation('running'); setCurrentAnimation('running');
break; break;
case '4': case '4':
setCurrentAnimation('jumping'); setCurrentAnimation('swimming');
break;
case 'm':
case 'M':
setIsMinimized(!isMinimized);
break; break;
} }
}; };
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('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; if (!texture) return null;
@@ -81,233 +178,304 @@ export default function SkinDetailModal({ isOpen, onClose, texture }: SkinDetail
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} 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} onClick={onClose}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
isolation: 'isolate'
}}
> >
<motion.div <motion.div
initial={{ scale: 0.9, opacity: 0 }} variants={getModalVariants()}
animate={{ scale: 1, opacity: 1 }} initial="initial"
exit={{ scale: 0.9, opacity: 0 }} animate={isMinimized ? "minimized" : "animate"}
exit="exit"
transition={{ type: "spring", damping: 20, stiffness: 300 }} 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()} onClick={(e) => e.stopPropagation()}
> >
{/* Header */} {/* 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 justify-between">
<div className="flex items-center space-x-4"> <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} {texture.name}
</h2> </motion.h2>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<span className={`px-3 py-1 text-sm rounded-full font-medium ${ <motion.span
className={`px-3 py-1 text-sm rounded-full font-medium ${
texture.type === 'SKIN' texture.type === 'SKIN'
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300' ? '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-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-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' ? '皮肤' : '披风'} {texture.type === 'SKIN' ? '皮肤' : '披风'}
</span> </motion.span>
{texture.is_slim && ( {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>
</div> </div>
<div className="flex items-center space-x-2">
<motion.button <motion.button
onClick={onClose} onClick={() => setIsMinimized(!isMinimized)}
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" 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 }} whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }} 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" /> <XMarkIcon className="w-6 h-6" />
</motion.button> </motion.button>
</div> </div>
</div> </div>
</motion.div>
{!isMinimized && (
<div className="flex h-full pt-20"> <div className="flex h-full pt-20">
{/* 3D 预览区域 */} {/* 3D 预览区域 */}
<div className="flex-1 flex items-center justify-center p-8"> <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 }}
>
<div className="w-full h-full max-w-2xl max-h-2xl"> <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 }
}}
>
<SkinViewer <SkinViewer
skinUrl={texture.url} skinUrl={texture.url}
isSlim={texture.is_slim} isSlim={texture.is_slim}
width={600} width={600}
height={600} height={600}
className="w-full h-full rounded-xl shadow-lg" className="w-full h-full rounded-xl shadow-2xl border-2 border-white/50 dark:border-gray-700/50"
autoRotate={autoRotate} autoRotate={autoRotate}
walking={currentAnimation === 'walking'} walking={currentAnimation === 'walking'}
running={currentAnimation === 'running'} running={currentAnimation === 'running'}
jumping={currentAnimation === 'jumping'} jumping={currentAnimation === 'swimming'}
rotation={rotation} rotation={rotation}
/> />
</motion.div>
</div> </div>
</div> </motion.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"> <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 }}
>
{/* 动画控制 */} {/* 动画控制 */}
<div className="space-y-4"> <motion.div className="space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center"> <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 }}
>
<PlayIcon className="w-5 h-5 mr-2" /> <PlayIcon className="w-5 h-5 mr-2" />
</h3> </motion.h3>
<div className="grid grid-cols-2 gap-2"> <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 <motion.button
onClick={() => setCurrentAnimation('idle')} 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 ${ className={`p-3 rounded-lg text-sm font-medium transition-all duration-200 ${
currentAnimation === 'idle' currentAnimation === anim.key
? 'bg-orange-500 text-white shadow-lg' ? '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-gray-100 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600' : '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'
}`} }`}
whileHover={{ scale: 1.02 }} initial={{ opacity: 0, y: 20 }}
whileTap={{ scale: 0.98 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6 + i * 0.1 }}
> >
{anim.label}
</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> </motion.button>
))}
</div> </div>
</div> </motion.div>
{/* 视角控制 */} {/* 信息面板 */}
<div className="space-y-4"> <motion.div
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center"> className="space-y-4"
<ArrowPathIcon className="w-5 h-5 mr-2" /> initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
</h3> transition={{ delay: 0.8 }}
<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 }}
> >
<ArrowPathIcon className="w-4 h-4 mr-2" /> <h3 className="text-lg font-semibold bg-gradient-to-r from-orange-500 to-amber-500 bg-clip-text text-transparent">
{autoRotate ? '停止旋转' : '自动旋转'}
</motion.button>
<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> </h3>
<div className="bg-white/50 dark:bg-gray-800/50 rounded-lg p-4 space-y-3">
{texture.description && ( {texture.description && (
<div> <motion.div
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2"></p> className="bg-white/50 dark:bg-gray-700/50 rounded-lg p-3"
<p className="text-sm text-gray-900 dark:text-white">{texture.description}</p> initial={{ opacity: 0, y: 10 }}
</div> 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="grid grid-cols-2 gap-4 text-sm"> <div className="space-y-2 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>
{texture.uploader && ( {texture.uploader && (
<div> <motion.div
<p className="text-gray-600 dark:text-gray-400 text-sm"></p> className="flex justify-between items-center"
<p className="font-semibold text-gray-900 dark:text-white">{texture.uploader.username}</p> initial={{ opacity: 0, x: -10 }}
</div> 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 && ( {texture.created_at && (
<div> <motion.div
<p className="text-gray-600 dark:text-gray-400 text-sm"></p> className="flex justify-between items-center"
<p className="font-semibold text-gray-900 dark:text-white"> initial={{ opacity: 0, x: -10 }}
{new Date(texture.created_at).toLocaleDateString('zh-CN')} animate={{ opacity: 1, x: 0 }}
</p> transition={{ delay: 1.1 }}
</div> >
<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> </div>
</div> </motion.div>
{/* 快捷键提示 */} {/* 快捷键提示 */}
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4"> <motion.div
<h4 className="text-sm font-semibold text-blue-800 dark:text-blue-300 mb-2 flex items-center"> className="bg-white/30 dark:bg-gray-700/30 rounded-lg p-3 text-xs text-gray-600 dark:text-gray-400"
<span className="w-4 h-4 mr-2"></span> initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
</h4> transition={{ delay: 1.4 }}
<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 className="font-medium mb-1">:</p>
<p><kbd className="px-1 py-0.5 bg-white/50 dark:bg-gray-800/50 rounded">1</kbd> </p> <p> - / | 1-4 - | M - | ESC - </p>
<p><kbd className="px-1 py-0.5 bg-white/50 dark:bg-gray-800/50 rounded">2</kbd> </p> </motion.div>
<p><kbd className="px-1 py-0.5 bg-white/50 dark:bg-gray-800/50 rounded">3</kbd> </p> </motion.div>
<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>
</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> </div>
</motion.div> </motion.div>
)}
</motion.div>
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>

View File

@@ -15,6 +15,7 @@ interface SkinViewerProps {
running?: boolean; // 新增:跑步动画 running?: boolean; // 新增:跑步动画
jumping?: boolean; // 新增:跳跃动画 jumping?: boolean; // 新增:跳跃动画
rotation?: boolean; // 新增:旋转控制 rotation?: boolean; // 新增:旋转控制
isExternalPreview?: boolean; // 新增:是否为外部预览
} }
export default function SkinViewer({ export default function SkinViewer({
@@ -29,6 +30,7 @@ export default function SkinViewer({
running = false, running = false,
jumping = false, jumping = false,
rotation = true, rotation = true,
isExternalPreview = false, // 新增默认为false
}: SkinViewerProps) { }: SkinViewerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const viewerRef = useRef<SkinViewer3D | null>(null); const viewerRef = useRef<SkinViewer3D | null>(null);
@@ -92,11 +94,19 @@ export default function SkinViewer({
// 设置背景和控制选项 - 参考blessingskin // 设置背景和控制选项 - 参考blessingskin
viewer.background = null; // 透明背景 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.enableRotate = rotation; // 根据参数控制旋转
viewer.controls.enableZoom = true; // 启用缩放 viewer.controls.enableZoom = true; // 启用缩放
}
viewer.controls.enablePan = false; // 禁用平移 viewer.controls.enablePan = false; // 禁用平移
console.log('3D皮肤查看器初始化成功'); 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 的实现 // 控制动画效果 - 参考 Blessing Skin 的实现
useEffect(() => { useEffect(() => {
@@ -126,6 +136,12 @@ export default function SkinViewer({
const viewer = viewerRef.current; const viewer = viewerRef.current;
// 外部预览时只使用静止动画,禁用所有其他动画
if (isExternalPreview) {
viewer.animation = new IdleAnimation();
viewer.autoRotate = false;
console.log('外部预览模式:启用静止动画,禁用旋转');
} else {
// 根据优先级设置动画 - 参考 Blessing Skin 的 animationFactories // 根据优先级设置动画 - 参考 Blessing Skin 的 animationFactories
if (running) { if (running) {
// 跑步动画 // 跑步动画
@@ -147,8 +163,9 @@ export default function SkinViewer({
// 更新自动旋转状态 // 更新自动旋转状态
viewer.autoRotate = autoRotate && !walking && !running && !jumping; viewer.autoRotate = autoRotate && !walking && !running && !jumping;
}
}, [walking, running, jumping, autoRotate]); }, [walking, running, jumping, autoRotate, isExternalPreview]);
// 当皮肤URL改变时更新 // 当皮肤URL改变时更新
useEffect(() => { useEffect(() => {

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

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

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

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

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

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