Initial commit: CarrotSkin project setup
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
246
src/components/EnhancedButton.tsx
Normal file
246
src/components/EnhancedButton.tsx
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { motion, MotionProps } from 'framer-motion';
|
||||||
|
import { ReactNode, useState, useEffect } from 'react';
|
||||||
|
import { AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
|
interface EnhancedButtonProps extends Omit<MotionProps, 'onClick'> {
|
||||||
|
children: ReactNode;
|
||||||
|
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void | Promise<void>;
|
||||||
|
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger';
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
disabled?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
icon?: ReactNode;
|
||||||
|
iconPosition?: 'left' | 'right';
|
||||||
|
ripple?: boolean;
|
||||||
|
sound?: boolean;
|
||||||
|
haptic?: boolean;
|
||||||
|
className?: string;
|
||||||
|
type?: 'button' | 'submit' | 'reset';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EnhancedButton({
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
disabled = false,
|
||||||
|
loading = false,
|
||||||
|
fullWidth = false,
|
||||||
|
icon,
|
||||||
|
iconPosition = 'left',
|
||||||
|
ripple = true,
|
||||||
|
sound = true,
|
||||||
|
haptic = true,
|
||||||
|
className = '',
|
||||||
|
type = 'button',
|
||||||
|
...motionProps
|
||||||
|
}: EnhancedButtonProps) {
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [ripples, setRipples] = useState<Array<{ id: number; x: number; y: number }>>([]);
|
||||||
|
|
||||||
|
// 播放音效
|
||||||
|
const playSound = (type: 'click' | 'success' | 'error' = 'click') => {
|
||||||
|
if (!sound) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||||
|
const oscillator = audioContext.createOscillator();
|
||||||
|
const gainNode = audioContext.createGain();
|
||||||
|
|
||||||
|
oscillator.connect(gainNode);
|
||||||
|
gainNode.connect(audioContext.destination);
|
||||||
|
|
||||||
|
const frequencies = {
|
||||||
|
click: 800,
|
||||||
|
success: 1000,
|
||||||
|
error: 400
|
||||||
|
};
|
||||||
|
|
||||||
|
oscillator.frequency.setValueAtTime(frequencies[type], audioContext.currentTime);
|
||||||
|
oscillator.type = type === 'error' ? 'sawtooth' : 'sine';
|
||||||
|
|
||||||
|
gainNode.gain.setValueAtTime(0.1, audioContext.currentTime);
|
||||||
|
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2);
|
||||||
|
|
||||||
|
oscillator.start(audioContext.currentTime);
|
||||||
|
oscillator.stop(audioContext.currentTime + 0.2);
|
||||||
|
} catch (error) {
|
||||||
|
// 忽略音频API错误
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 触觉反馈
|
||||||
|
const triggerHaptic = () => {
|
||||||
|
if (haptic && navigator.vibrate) {
|
||||||
|
navigator.vibrate(50);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建涟漪效果
|
||||||
|
const createRipple = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
if (!ripple) return;
|
||||||
|
|
||||||
|
const button = e.currentTarget;
|
||||||
|
const rect = button.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const y = e.clientY - rect.top;
|
||||||
|
const newRipple = { id: Date.now(), x, y };
|
||||||
|
|
||||||
|
setRipples(prev => [...prev, newRipple]);
|
||||||
|
|
||||||
|
// 移除涟漪
|
||||||
|
setTimeout(() => {
|
||||||
|
setRipples(prev => prev.filter(r => r.id !== newRipple.id));
|
||||||
|
}, 600);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理点击事件
|
||||||
|
const handleClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
if (disabled || loading || isProcessing) return;
|
||||||
|
|
||||||
|
createRipple(e);
|
||||||
|
playSound('click');
|
||||||
|
triggerHaptic();
|
||||||
|
|
||||||
|
if (onClick) {
|
||||||
|
try {
|
||||||
|
setIsProcessing(true);
|
||||||
|
const result = onClick(e);
|
||||||
|
|
||||||
|
// 如果返回的是 Promise,等待它完成
|
||||||
|
if (result instanceof Promise) {
|
||||||
|
await result;
|
||||||
|
playSound('success');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
playSound('error');
|
||||||
|
console.error('Button click error:', error);
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getVariantStyles = () => {
|
||||||
|
const baseStyles = 'relative overflow-hidden transition-all duration-200 font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
primary: 'bg-gradient-to-r from-orange-500 to-amber-500 text-white hover:from-orange-600 hover:to-amber-600 focus:ring-orange-500 shadow-lg hover:shadow-xl',
|
||||||
|
secondary: 'bg-gradient-to-r from-gray-500 to-gray-600 text-white hover:from-gray-600 hover:to-gray-700 focus:ring-gray-500 shadow-md hover:shadow-lg',
|
||||||
|
outline: 'border-2 border-orange-500 text-orange-500 hover:bg-orange-500 hover:text-white focus:ring-orange-500',
|
||||||
|
ghost: 'text-orange-500 hover:bg-orange-100 hover:text-orange-600 focus:ring-orange-500 dark:hover:bg-orange-900/20',
|
||||||
|
danger: 'bg-gradient-to-r from-red-500 to-pink-500 text-white hover:from-red-600 hover:to-pink-600 focus:ring-red-500 shadow-lg hover:shadow-xl'
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: 'px-3 py-1.5 text-sm rounded-md',
|
||||||
|
md: 'px-4 py-2 text-base rounded-lg',
|
||||||
|
lg: 'px-6 py-3 text-lg rounded-xl'
|
||||||
|
};
|
||||||
|
|
||||||
|
return `${baseStyles} ${variants[variant]} ${sizes[size]} ${fullWidth ? 'w-full' : ''} ${className}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buttonVariants = {
|
||||||
|
initial: { scale: 1 },
|
||||||
|
hover: {
|
||||||
|
scale: 1.02,
|
||||||
|
transition: { duration: 0.2, ease: "easeOut" }
|
||||||
|
},
|
||||||
|
tap: {
|
||||||
|
scale: 0.98,
|
||||||
|
transition: { duration: 0.1 }
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
scale: 0.95,
|
||||||
|
transition: { duration: 0.2 }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDisabled = disabled || loading || isProcessing;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
type={type}
|
||||||
|
onClick={handleClick}
|
||||||
|
disabled={isDisabled}
|
||||||
|
className={getVariantStyles()}
|
||||||
|
variants={buttonVariants}
|
||||||
|
initial="initial"
|
||||||
|
whileHover={!isDisabled ? "hover" : undefined}
|
||||||
|
whileTap={!isDisabled ? "tap" : undefined}
|
||||||
|
animate={loading || isProcessing ? "loading" : "initial"}
|
||||||
|
{...motionProps}
|
||||||
|
>
|
||||||
|
{/* 涟漪效果 */}
|
||||||
|
{ripple && (
|
||||||
|
<span className="absolute inset-0 overflow-hidden rounded-inherit">
|
||||||
|
{ripples.map(ripple => (
|
||||||
|
<motion.span
|
||||||
|
key={ripple.id}
|
||||||
|
className="absolute bg-white/30 rounded-full pointer-events-none"
|
||||||
|
style={{
|
||||||
|
left: ripple.x - 10,
|
||||||
|
top: ripple.y - 10,
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
}}
|
||||||
|
initial={{ scale: 0, opacity: 1 }}
|
||||||
|
animate={{ scale: 4, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 加载状态 */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{(loading || isProcessing) && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.8 }}
|
||||||
|
className="absolute inset-0 flex items-center justify-center bg-inherit rounded-inherit"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
animate={{ rotate: 360 }}
|
||||||
|
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||||
|
className="w-4 h-4 border-2 border-current border-t-transparent rounded-full"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* 按钮内容 */}
|
||||||
|
<motion.span
|
||||||
|
className={`flex items-center justify-center space-x-2 transition-opacity duration-200 ${
|
||||||
|
loading || isProcessing ? 'opacity-0' : 'opacity-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{icon && iconPosition === 'left' && (
|
||||||
|
<motion.span
|
||||||
|
initial={{ scale: 0.8, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
transition={{ delay: 0.1 }}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</motion.span>
|
||||||
|
)}
|
||||||
|
<span>{children}</span>
|
||||||
|
{icon && iconPosition === 'right' && (
|
||||||
|
<motion.span
|
||||||
|
initial={{ scale: 0.8, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
transition={{ delay: 0.1 }}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</motion.span>
|
||||||
|
)}
|
||||||
|
</motion.span>
|
||||||
|
</motion.button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
432
src/components/EnhancedInput.tsx
Normal file
432
src/components/EnhancedInput.tsx
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { ReactNode, useState, useRef, useEffect, forwardRef } from 'react';
|
||||||
|
import { EyeIcon, EyeSlashIcon, ExclamationCircleIcon, CheckCircleIcon } from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
interface EnhancedInputProps {
|
||||||
|
type?: 'text' | 'password' | 'email' | 'number' | 'tel' | 'url' | 'search';
|
||||||
|
placeholder?: string;
|
||||||
|
value?: string;
|
||||||
|
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
onFocus?: (e: React.FocusEvent<HTMLInputElement>) => void;
|
||||||
|
onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
success?: string;
|
||||||
|
hint?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
leftIcon?: ReactNode;
|
||||||
|
rightIcon?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
containerClassName?: string;
|
||||||
|
autoFocus?: boolean;
|
||||||
|
autoComplete?: string;
|
||||||
|
name?: string;
|
||||||
|
id?: string;
|
||||||
|
showPasswordToggle?: boolean;
|
||||||
|
showStrengthIndicator?: boolean;
|
||||||
|
showCharCount?: boolean;
|
||||||
|
maxLength?: number;
|
||||||
|
minLength?: number;
|
||||||
|
pattern?: string;
|
||||||
|
validate?: (value: string) => string | null;
|
||||||
|
onValidationChange?: (isValid: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EnhancedInput = forwardRef<HTMLInputElement, EnhancedInputProps>(({
|
||||||
|
type = 'text',
|
||||||
|
placeholder,
|
||||||
|
value = '',
|
||||||
|
onChange,
|
||||||
|
onFocus,
|
||||||
|
onBlur,
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
success,
|
||||||
|
hint,
|
||||||
|
disabled = false,
|
||||||
|
required = false,
|
||||||
|
leftIcon,
|
||||||
|
rightIcon,
|
||||||
|
className = '',
|
||||||
|
containerClassName = '',
|
||||||
|
autoFocus = false,
|
||||||
|
autoComplete,
|
||||||
|
name,
|
||||||
|
id,
|
||||||
|
showPasswordToggle = false,
|
||||||
|
showStrengthIndicator = false,
|
||||||
|
showCharCount = false,
|
||||||
|
maxLength,
|
||||||
|
minLength,
|
||||||
|
pattern,
|
||||||
|
validate,
|
||||||
|
onValidationChange,
|
||||||
|
...props
|
||||||
|
}, ref) => {
|
||||||
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
|
||||||
|
const [internalValue, setInternalValue] = useState(value);
|
||||||
|
const [validationMessage, setValidationMessage] = useState<string | null>(null);
|
||||||
|
const [isValidating, setIsValidating] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 同步外部值
|
||||||
|
useEffect(() => {
|
||||||
|
setInternalValue(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
// 验证逻辑
|
||||||
|
useEffect(() => {
|
||||||
|
if (validate && internalValue) {
|
||||||
|
setIsValidating(true);
|
||||||
|
const message = validate(internalValue);
|
||||||
|
setValidationMessage(message);
|
||||||
|
setIsValidating(false);
|
||||||
|
|
||||||
|
if (onValidationChange) {
|
||||||
|
onValidationChange(!message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [internalValue, validate, onValidationChange]);
|
||||||
|
|
||||||
|
// 密码强度计算
|
||||||
|
const getPasswordStrength = (password: string) => {
|
||||||
|
let strength = 0;
|
||||||
|
if (password.length >= 8) strength++;
|
||||||
|
if (/[a-z]/.test(password)) strength++;
|
||||||
|
if (/[A-Z]/.test(password)) strength++;
|
||||||
|
if (/[0-9]/.test(password)) strength++;
|
||||||
|
if (/[^A-Za-z0-9]/.test(password)) strength++;
|
||||||
|
return strength;
|
||||||
|
};
|
||||||
|
|
||||||
|
const passwordStrength = type === 'password' && showStrengthIndicator ? getPasswordStrength(internalValue) : 0;
|
||||||
|
|
||||||
|
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||||
|
setIsFocused(true);
|
||||||
|
onFocus?.(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||||
|
setIsFocused(false);
|
||||||
|
onBlur?.(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newValue = e.target.value;
|
||||||
|
setInternalValue(newValue);
|
||||||
|
onChange?.(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePasswordVisibility = () => {
|
||||||
|
setIsPasswordVisible(!isPasswordVisible);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getInputType = () => {
|
||||||
|
if (type === 'password' && showPasswordToggle) {
|
||||||
|
return isPasswordVisible ? 'text' : 'password';
|
||||||
|
}
|
||||||
|
return type;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBorderColor = () => {
|
||||||
|
if (error || validationMessage) return 'border-red-500 focus:border-red-500 focus:ring-red-500';
|
||||||
|
if (success || (validationMessage === null && internalValue && validate)) return 'border-green-500 focus:border-green-500 focus:ring-green-500';
|
||||||
|
if (isFocused) return 'border-orange-500 focus:border-orange-500 focus:ring-orange-500';
|
||||||
|
return 'border-gray-300 dark:border-gray-600 focus:border-orange-500 focus:ring-orange-500';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLabelColor = () => {
|
||||||
|
if (error || validationMessage) return 'text-red-600 dark:text-red-400';
|
||||||
|
if (success || (validationMessage === null && internalValue && validate)) return 'text-green-600 dark:text-green-400';
|
||||||
|
if (isFocused) return 'text-orange-600 dark:text-orange-400';
|
||||||
|
return 'text-gray-700 dark:text-gray-300';
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputVariants = {
|
||||||
|
initial: { scale: 1 },
|
||||||
|
focus: {
|
||||||
|
scale: 1.02,
|
||||||
|
transition: { duration: 0.2, ease: "easeOut" }
|
||||||
|
},
|
||||||
|
blur: {
|
||||||
|
scale: 1,
|
||||||
|
transition: { duration: 0.2, ease: "easeOut" }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelVariants = {
|
||||||
|
initial: { y: 0, scale: 1 },
|
||||||
|
focus: {
|
||||||
|
y: -20,
|
||||||
|
scale: 0.85,
|
||||||
|
transition: { duration: 0.2, ease: "easeOut" }
|
||||||
|
},
|
||||||
|
blur: {
|
||||||
|
y: internalValue ? -20 : 0,
|
||||||
|
scale: internalValue ? 0.85 : 1,
|
||||||
|
transition: { duration: 0.2, ease: "easeOut" }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
ref={containerRef}
|
||||||
|
className={`relative ${containerClassName}`}
|
||||||
|
initial="initial"
|
||||||
|
animate={isFocused ? "focus" : "blur"}
|
||||||
|
variants={inputVariants}
|
||||||
|
>
|
||||||
|
{/* 标签 */}
|
||||||
|
{label && (
|
||||||
|
<motion.label
|
||||||
|
htmlFor={id}
|
||||||
|
className={`absolute left-3 z-10 bg-white dark:bg-gray-800 px-1 text-sm font-medium transition-all duration-200 ${
|
||||||
|
isFocused || internalValue ? 'text-xs' : 'text-base'
|
||||||
|
} ${getLabelColor()}`}
|
||||||
|
initial="initial"
|
||||||
|
animate={isFocused ? "focus" : "blur"}
|
||||||
|
variants={labelVariants}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-red-500 ml-1">*</span>}
|
||||||
|
</motion.label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 输入框容器 */}
|
||||||
|
<motion.div
|
||||||
|
className={`relative flex items-center bg-white dark:bg-gray-800 border-2 rounded-lg transition-all duration-200 ${
|
||||||
|
getBorderColor()
|
||||||
|
} ${disabled ? 'bg-gray-100 dark:bg-gray-700 cursor-not-allowed' : ''}`}
|
||||||
|
whileHover={!disabled ? { scale: 1.01 } : {}}
|
||||||
|
animate={{
|
||||||
|
boxShadow: isFocused ? '0 0 0 3px rgba(249, 115, 22, 0.1)' : '0 1px 3px 0 rgba(0, 0, 0, 0.1)'
|
||||||
|
}}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
{/* 左侧图标 */}
|
||||||
|
{leftIcon && (
|
||||||
|
<motion.div
|
||||||
|
className="absolute left-3 text-gray-400 dark:text-gray-500"
|
||||||
|
initial={{ opacity: 0, x: -10 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: 0.1 }}
|
||||||
|
>
|
||||||
|
{leftIcon}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 输入框 */}
|
||||||
|
<input
|
||||||
|
ref={ref || inputRef}
|
||||||
|
type={getInputType()}
|
||||||
|
placeholder={!label ? placeholder : ''}
|
||||||
|
value={internalValue}
|
||||||
|
onChange={handleChange}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
disabled={disabled}
|
||||||
|
required={required}
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
autoComplete={autoComplete}
|
||||||
|
name={name}
|
||||||
|
id={id}
|
||||||
|
maxLength={maxLength}
|
||||||
|
minLength={minLength}
|
||||||
|
pattern={pattern}
|
||||||
|
className={`w-full bg-transparent border-0 outline-none focus:ring-0 transition-all duration-200 ${
|
||||||
|
leftIcon ? 'pl-10' : 'pl-4'
|
||||||
|
} ${
|
||||||
|
rightIcon || (type === 'password' && showPasswordToggle) ? 'pr-10' : 'pr-4'
|
||||||
|
} ${
|
||||||
|
size === 'sm' ? 'py-2 text-sm' : size === 'lg' ? 'py-3 text-lg' : 'py-2.5 text-base'
|
||||||
|
} ${className}`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 验证状态图标 */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isValidating && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.8 }}
|
||||||
|
className="absolute right-3 text-orange-500"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
animate={{ rotate: 360 }}
|
||||||
|
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||||
|
className="w-4 h-4 border-2 border-orange-500 border-t-transparent rounded-full"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isValidating && validationMessage === null && internalValue && validate && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.8 }}
|
||||||
|
className="absolute right-3 text-green-500"
|
||||||
|
>
|
||||||
|
<CheckCircleIcon className="w-5 h-5" />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(error || validationMessage) && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.8 }}
|
||||||
|
className="absolute right-3 text-red-500"
|
||||||
|
>
|
||||||
|
<ExclamationCircleIcon className="w-5 h-5" />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* 右侧图标和密码切换 */}
|
||||||
|
<div className="absolute right-3 flex items-center space-x-2">
|
||||||
|
{type === 'password' && showPasswordToggle && (
|
||||||
|
<motion.button
|
||||||
|
type="button"
|
||||||
|
onClick={togglePasswordVisibility}
|
||||||
|
className="text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300 transition-colors duration-200"
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
whileTap={{ scale: 0.9 }}
|
||||||
|
>
|
||||||
|
{isPasswordVisible ? <EyeSlashIcon className="w-5 h-5" /> : <EyeIcon className="w-5 h-5" />}
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rightIcon && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: 10 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: 0.1 }}
|
||||||
|
>
|
||||||
|
{rightIcon}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 密码强度指示器 */}
|
||||||
|
{type === 'password' && showStrengthIndicator && internalValue && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
exit={{ opacity: 0, height: 0 }}
|
||||||
|
className="mt-2"
|
||||||
|
>
|
||||||
|
<div className="flex space-x-1">
|
||||||
|
{[1, 2, 3, 4, 5].map((level) => (
|
||||||
|
<motion.div
|
||||||
|
key={level}
|
||||||
|
className={`h-1 flex-1 rounded-full transition-all duration-300 ${
|
||||||
|
passwordStrength >= level
|
||||||
|
? level <= 2
|
||||||
|
? 'bg-red-500'
|
||||||
|
: level <= 3
|
||||||
|
? 'bg-yellow-500'
|
||||||
|
: 'bg-green-500'
|
||||||
|
: 'bg-gray-200 dark:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
initial={{ scaleX: 0 }}
|
||||||
|
animate={{ scaleX: passwordStrength >= level ? 1 : 0.3 }}
|
||||||
|
transition={{ delay: level * 0.1 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<motion.p
|
||||||
|
className="text-xs mt-1 text-gray-600 dark:text-gray-400"
|
||||||
|
initial={{ opacity: 0, y: -5 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.2 }}
|
||||||
|
>
|
||||||
|
{passwordStrength <= 2 && '密码强度:弱'}
|
||||||
|
{passwordStrength === 3 && '密码强度:中等'}
|
||||||
|
{passwordStrength >= 4 && '密码强度:强'}
|
||||||
|
</motion.p>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 字符计数 */}
|
||||||
|
{showCharCount && maxLength && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="flex justify-between items-center mt-1"
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
<motion.span
|
||||||
|
className={`text-xs ${
|
||||||
|
internalValue.length > maxLength
|
||||||
|
? 'text-red-500'
|
||||||
|
: internalValue.length > maxLength * 0.8
|
||||||
|
? 'text-yellow-500'
|
||||||
|
: 'text-gray-500'
|
||||||
|
}`}
|
||||||
|
animate={{
|
||||||
|
scale: internalValue.length > maxLength ? 1.1 : 1,
|
||||||
|
color: internalValue.length > maxLength ? '#ef4444' : internalValue.length > maxLength * 0.8 ? '#f59e0b' : '#6b7280'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{internalValue.length}/{maxLength}
|
||||||
|
</motion.span>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 错误信息 */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{(error || validationMessage) && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto', y: 0 }}
|
||||||
|
exit={{ opacity: 0, height: 0, y: -10 }}
|
||||||
|
className="mt-1 text-sm text-red-600 dark:text-red-400 flex items-center space-x-1"
|
||||||
|
>
|
||||||
|
<ExclamationCircleIcon className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span>{error || validationMessage}</span>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* 成功信息 */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{success && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto', y: 0 }}
|
||||||
|
exit={{ opacity: 0, height: 0, y: -10 }}
|
||||||
|
className="mt-1 text-sm text-green-600 dark:text-green-400 flex items-center space-x-1"
|
||||||
|
>
|
||||||
|
<CheckCircleIcon className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span>{success}</span>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* 提示信息 */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{hint && !error && !validationMessage && !success && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, height: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, height: 'auto', y: 0 }}
|
||||||
|
exit={{ opacity: 0, height: 0, y: -10 }}
|
||||||
|
className="mt-1 text-sm text-gray-600 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
{hint}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
EnhancedInput.displayName = 'EnhancedInput';
|
||||||
|
|
||||||
|
export default EnhancedInput;
|
||||||
@@ -15,16 +15,26 @@ interface ErrorNotificationProps {
|
|||||||
|
|
||||||
export function ErrorNotification({ message, type = 'error', duration = 5000, onClose }: ErrorNotificationProps) {
|
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>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
|||||||
446
src/components/LoadingStates.tsx
Normal file
446
src/components/LoadingStates.tsx
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface LoadingSpinnerProps {
|
||||||
|
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
color?: 'orange' | 'blue' | 'green' | 'red' | 'purple' | 'gray';
|
||||||
|
speed?: 'slow' | 'normal' | 'fast';
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingSpinner({
|
||||||
|
size = 'md',
|
||||||
|
color = 'orange',
|
||||||
|
speed = 'normal',
|
||||||
|
className = ''
|
||||||
|
}: LoadingSpinnerProps) {
|
||||||
|
const sizes = {
|
||||||
|
sm: 'w-4 h-4',
|
||||||
|
md: 'w-6 h-6',
|
||||||
|
lg: 'w-8 h-8',
|
||||||
|
xl: 'w-12 h-12'
|
||||||
|
};
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
orange: 'border-orange-500',
|
||||||
|
blue: 'border-blue-500',
|
||||||
|
green: 'border-green-500',
|
||||||
|
red: 'border-red-500',
|
||||||
|
purple: 'border-purple-500',
|
||||||
|
gray: 'border-gray-500'
|
||||||
|
};
|
||||||
|
|
||||||
|
const speeds = {
|
||||||
|
slow: 'duration-2000',
|
||||||
|
normal: 'duration-1000',
|
||||||
|
fast: 'duration-500'
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className={`${sizes[size]} ${colors[color]} border-2 border-t-transparent rounded-full animate-spin ${className}`}
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoadingDotsProps {
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
color?: 'orange' | 'blue' | 'green' | 'red' | 'purple' | 'gray';
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingDots({
|
||||||
|
size = 'md',
|
||||||
|
color = 'orange',
|
||||||
|
className = ''
|
||||||
|
}: LoadingDotsProps) {
|
||||||
|
const sizes = {
|
||||||
|
sm: 'w-2 h-2',
|
||||||
|
md: 'w-3 h-3',
|
||||||
|
lg: 'w-4 h-4'
|
||||||
|
};
|
||||||
|
|
||||||
|
const colors = {
|
||||||
|
orange: 'bg-orange-500',
|
||||||
|
blue: 'bg-blue-500',
|
||||||
|
green: 'bg-green-500',
|
||||||
|
red: 'bg-red-500',
|
||||||
|
purple: 'bg-purple-500',
|
||||||
|
gray: 'bg-gray-500'
|
||||||
|
};
|
||||||
|
|
||||||
|
const dotVariants = {
|
||||||
|
initial: { y: 0 },
|
||||||
|
animate: {
|
||||||
|
y: [-8, 0, -8],
|
||||||
|
transition: {
|
||||||
|
duration: 0.6,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "easeInOut"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex space-x-1 ${className}`}>
|
||||||
|
{[0, 1, 2].map((index) => (
|
||||||
|
<motion.div
|
||||||
|
key={index}
|
||||||
|
className={`${sizes[size]} ${colors[color]} rounded-full`}
|
||||||
|
variants={dotVariants}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
transition={{ delay: index * 0.1 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkeletonProps {
|
||||||
|
width?: string | number;
|
||||||
|
height?: string | number;
|
||||||
|
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||||
|
animated?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Skeleton({
|
||||||
|
width = '100%',
|
||||||
|
height = '1rem',
|
||||||
|
rounded = 'md',
|
||||||
|
animated = true,
|
||||||
|
className = ''
|
||||||
|
}: SkeletonProps) {
|
||||||
|
const roundedClasses = {
|
||||||
|
none: 'rounded-none',
|
||||||
|
sm: 'rounded-sm',
|
||||||
|
md: 'rounded-md',
|
||||||
|
lg: 'rounded-lg',
|
||||||
|
xl: 'rounded-xl',
|
||||||
|
full: 'rounded-full'
|
||||||
|
};
|
||||||
|
|
||||||
|
const shimmerVariants = {
|
||||||
|
initial: { x: '-100%' },
|
||||||
|
animate: {
|
||||||
|
x: '100%',
|
||||||
|
transition: {
|
||||||
|
duration: 1.5,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "linear"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className={`bg-gray-200 dark:bg-gray-700 ${roundedClasses[rounded]} relative overflow-hidden ${className}`}
|
||||||
|
style={{ width, height }}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
{animated && (
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 dark:via-white/10 to-transparent"
|
||||||
|
variants={shimmerVariants}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkeletonCardProps {
|
||||||
|
lines?: number;
|
||||||
|
image?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkeletonCard({ lines = 3, image = true, className = '' }: SkeletonCardProps) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className={`bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 space-y-3 ${className}`}
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
{image && (
|
||||||
|
<Skeleton width="100%" height="12rem" rounded="md" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Array.from({ length: lines }).map((_, index) => (
|
||||||
|
<Skeleton
|
||||||
|
key={index}
|
||||||
|
width={index === lines - 1 ? '60%' : '100%'}
|
||||||
|
height="1rem"
|
||||||
|
rounded="sm"
|
||||||
|
animated={true}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoadingProgressBarProps {
|
||||||
|
progress?: number;
|
||||||
|
color?: 'orange' | 'blue' | 'green' | 'red' | 'purple';
|
||||||
|
animated?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingProgressBar({
|
||||||
|
progress = 0,
|
||||||
|
color = 'orange',
|
||||||
|
animated = true,
|
||||||
|
className = ''
|
||||||
|
}: LoadingProgressBarProps) {
|
||||||
|
const colors = {
|
||||||
|
orange: 'bg-gradient-to-r from-orange-400 to-amber-500',
|
||||||
|
blue: 'bg-gradient-to-r from-blue-400 to-cyan-500',
|
||||||
|
green: 'bg-gradient-to-r from-green-400 to-emerald-500',
|
||||||
|
red: 'bg-gradient-to-r from-red-400 to-pink-500',
|
||||||
|
purple: 'bg-gradient-to-r from-purple-400 to-violet-500'
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className={`w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden ${className}`}
|
||||||
|
initial={{ opacity: 0, scaleX: 0 }}
|
||||||
|
animate={{ opacity: 1, scaleX: 1 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className={`h-full ${colors[color]} rounded-full`}
|
||||||
|
initial={{ width: 0 }}
|
||||||
|
animate={{ width: `${Math.min(Math.max(progress, 0), 100)}%` }}
|
||||||
|
transition={{
|
||||||
|
duration: animated ? 0.5 : 0,
|
||||||
|
ease: "easeOut"
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PulseLoaderProps {
|
||||||
|
color?: 'orange' | 'blue' | 'green' | 'red' | 'purple' | 'gray';
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PulseLoader({
|
||||||
|
color = 'orange',
|
||||||
|
size = 'md',
|
||||||
|
className = ''
|
||||||
|
}: PulseLoaderProps) {
|
||||||
|
const colors = {
|
||||||
|
orange: 'bg-orange-500',
|
||||||
|
blue: 'bg-blue-500',
|
||||||
|
green: 'bg-green-500',
|
||||||
|
red: 'bg-red-500',
|
||||||
|
purple: 'bg-purple-500',
|
||||||
|
gray: 'bg-gray-500'
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: 'w-8 h-8',
|
||||||
|
md: 'w-12 h-12',
|
||||||
|
lg: 'w-16 h-16'
|
||||||
|
};
|
||||||
|
|
||||||
|
const pulseVariants = {
|
||||||
|
initial: { scale: 0.8, opacity: 0.6 },
|
||||||
|
animate: {
|
||||||
|
scale: [0.8, 1.2, 0.8],
|
||||||
|
opacity: [0.6, 1, 0.6],
|
||||||
|
transition: {
|
||||||
|
duration: 1.5,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "easeInOut"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className={`${sizes[size]} ${colors[color]} rounded-full ${className}`}
|
||||||
|
variants={pulseVariants}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoadingContainerProps {
|
||||||
|
isLoading: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
loadingType?: 'spinner' | 'dots' | 'pulse' | 'skeleton';
|
||||||
|
loadingText?: string;
|
||||||
|
skeletonCount?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingContainer({
|
||||||
|
isLoading,
|
||||||
|
children,
|
||||||
|
loadingType = 'spinner',
|
||||||
|
loadingText = '加载中...',
|
||||||
|
skeletonCount = 3,
|
||||||
|
className = ''
|
||||||
|
}: LoadingContainerProps) {
|
||||||
|
return (
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{isLoading ? (
|
||||||
|
<motion.div
|
||||||
|
key="loading"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className={`flex flex-col items-center justify-center p-8 ${className}`}
|
||||||
|
>
|
||||||
|
{loadingType === 'spinner' && <LoadingSpinner size="lg" />}
|
||||||
|
{loadingType === 'dots' && <LoadingDots size="lg" />}
|
||||||
|
{loadingType === 'pulse' && <PulseLoader size="md" />}
|
||||||
|
{loadingType === 'skeleton' && (
|
||||||
|
<div className="w-full space-y-4">
|
||||||
|
{Array.from({ length: skeletonCount }).map((_, index) => (
|
||||||
|
<SkeletonCard key={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadingText && loadingType !== 'skeleton' && (
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.2 }}
|
||||||
|
className="mt-4 text-gray-600 dark:text-gray-400 font-medium"
|
||||||
|
>
|
||||||
|
{loadingText}
|
||||||
|
</motion.p>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
key="content"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InfiniteScrollLoaderProps {
|
||||||
|
isLoading: boolean;
|
||||||
|
hasMore: boolean;
|
||||||
|
onLoadMore: () => void;
|
||||||
|
threshold?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InfiniteScrollLoader({
|
||||||
|
isLoading,
|
||||||
|
hasMore,
|
||||||
|
onLoadMore,
|
||||||
|
threshold = 0.8,
|
||||||
|
className = ''
|
||||||
|
}: InfiniteScrollLoaderProps) {
|
||||||
|
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
|
||||||
|
const scrollPercentage = (scrollTop + clientHeight) / scrollHeight;
|
||||||
|
|
||||||
|
if (scrollPercentage > threshold && !isLoading && hasMore) {
|
||||||
|
onLoadMore();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} onScroll={handleScroll}>
|
||||||
|
{isLoading && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="flex justify-center py-4"
|
||||||
|
>
|
||||||
|
<LoadingSpinner size="md" />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasMore && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="text-center py-4 text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
没有更多内容了
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 骨架屏组件集合
|
||||||
|
export function SkeletonSkinCard() {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-4 space-y-4">
|
||||||
|
<Skeleton width="100%" height="12rem" rounded="lg" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton width="80%" height="1.5rem" rounded="sm" />
|
||||||
|
<Skeleton width="60%" height="1rem" rounded="sm" />
|
||||||
|
<Skeleton width="40%" height="1rem" rounded="sm" />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Skeleton width="30%" height="2rem" rounded="md" />
|
||||||
|
<Skeleton width="20%" height="2rem" rounded="md" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkeletonProfileCard() {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 space-y-4">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<Skeleton width="4rem" height="4rem" rounded="full" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Skeleton width="60%" height="1.5rem" rounded="sm" />
|
||||||
|
<Skeleton width="40%" height="1rem" rounded="sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton width="100%" height="1rem" rounded="sm" />
|
||||||
|
<Skeleton width="80%" height="1rem" rounded="sm" />
|
||||||
|
<Skeleton width="60%" height="1rem" rounded="sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkeletonNavbar() {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 shadow-md p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Skeleton width="8rem" height="2rem" rounded="md" />
|
||||||
|
<div className="flex space-x-4">
|
||||||
|
<Skeleton width="4rem" height="2rem" rounded="md" />
|
||||||
|
<Skeleton width="4rem" height="2rem" rounded="md" />
|
||||||
|
<Skeleton width="4rem" height="2rem" rounded="md" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -6,11 +6,12 @@ export function MainContent({ children }: { children: React.ReactNode }) {
|
|||||||
const pathname = usePathname();
|
const 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>
|
||||||
|
|||||||
381
src/components/MessageNotification.tsx
Normal file
381
src/components/MessageNotification.tsx
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
XMarkIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
InformationCircleIcon,
|
||||||
|
XCircleIcon,
|
||||||
|
BellIcon
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
export type MessageType = 'success' | 'error' | 'warning' | 'info' | 'loading';
|
||||||
|
|
||||||
|
export interface MessageNotificationProps {
|
||||||
|
message: string;
|
||||||
|
type?: MessageType;
|
||||||
|
duration?: number;
|
||||||
|
onClose?: () => void;
|
||||||
|
title?: string;
|
||||||
|
position?: 'top-left' | 'top-right' | 'top-center' | 'bottom-left' | 'bottom-right' | 'bottom-center';
|
||||||
|
showProgress?: boolean;
|
||||||
|
closable?: boolean;
|
||||||
|
action?: {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Message extends MessageNotificationProps {
|
||||||
|
id: string;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageNotification({
|
||||||
|
message,
|
||||||
|
type = 'info',
|
||||||
|
duration = 3000,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
position = 'top-right',
|
||||||
|
showProgress = true,
|
||||||
|
closable = true,
|
||||||
|
action
|
||||||
|
}: MessageNotificationProps) {
|
||||||
|
const [isVisible, setIsVisible] = useState(true);
|
||||||
|
const [remainingTime, setRemainingTime] = useState(duration);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (duration > 0 && type !== 'loading') {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setIsVisible(false);
|
||||||
|
onClose?.();
|
||||||
|
}, duration);
|
||||||
|
|
||||||
|
// 进度条更新
|
||||||
|
const progressTimer = setInterval(() => {
|
||||||
|
setRemainingTime(prev => {
|
||||||
|
const newTime = prev - 100;
|
||||||
|
if (newTime <= 0) {
|
||||||
|
clearInterval(progressTimer);
|
||||||
|
}
|
||||||
|
return newTime;
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
clearInterval(progressTimer);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [duration, onClose, type]);
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setIsVisible(false);
|
||||||
|
onClose?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIcon = () => {
|
||||||
|
const iconClass = "w-5 h-5";
|
||||||
|
switch (type) {
|
||||||
|
case 'error':
|
||||||
|
return <XCircleIcon className={iconClass} />;
|
||||||
|
case 'warning':
|
||||||
|
return <ExclamationTriangleIcon className={iconClass} />;
|
||||||
|
case 'success':
|
||||||
|
return <CheckCircleIcon className={iconClass} />;
|
||||||
|
case 'loading':
|
||||||
|
return (
|
||||||
|
<div className="animate-spin">
|
||||||
|
<svg className={iconClass} viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'info':
|
||||||
|
default:
|
||||||
|
return <InformationCircleIcon className={iconClass} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStyles = () => {
|
||||||
|
switch (type) {
|
||||||
|
case 'error':
|
||||||
|
return {
|
||||||
|
bg: 'bg-red-50 dark:bg-red-900/20',
|
||||||
|
border: 'border-red-200 dark:border-red-800',
|
||||||
|
text: 'text-red-800 dark:text-red-200',
|
||||||
|
icon: 'text-red-500',
|
||||||
|
close: 'text-red-400 hover:text-red-600 dark:text-red-300 dark:hover:text-red-100',
|
||||||
|
progress: 'bg-red-500',
|
||||||
|
action: 'bg-red-500 hover:bg-red-600 text-white'
|
||||||
|
};
|
||||||
|
case 'warning':
|
||||||
|
return {
|
||||||
|
bg: 'bg-yellow-50 dark:bg-yellow-900/20',
|
||||||
|
border: 'border-yellow-200 dark:border-yellow-800',
|
||||||
|
text: 'text-yellow-800 dark:text-yellow-200',
|
||||||
|
icon: 'text-yellow-500',
|
||||||
|
close: 'text-yellow-400 hover:text-yellow-600 dark:text-yellow-300 dark:hover:text-yellow-100',
|
||||||
|
progress: 'bg-yellow-500',
|
||||||
|
action: 'bg-yellow-500 hover:bg-yellow-600 text-white'
|
||||||
|
};
|
||||||
|
case 'success':
|
||||||
|
return {
|
||||||
|
bg: 'bg-green-50 dark:bg-green-900/20',
|
||||||
|
border: 'border-green-200 dark:border-green-800',
|
||||||
|
text: 'text-green-800 dark:text-green-200',
|
||||||
|
icon: 'text-green-500',
|
||||||
|
close: 'text-green-400 hover:text-green-600 dark:text-green-300 dark:hover:text-green-100',
|
||||||
|
progress: 'bg-green-500',
|
||||||
|
action: 'bg-green-500 hover:bg-green-600 text-white'
|
||||||
|
};
|
||||||
|
case 'loading':
|
||||||
|
return {
|
||||||
|
bg: 'bg-blue-50 dark:bg-blue-900/20',
|
||||||
|
border: 'border-blue-200 dark:border-blue-800',
|
||||||
|
text: 'text-blue-800 dark:text-blue-200',
|
||||||
|
icon: 'text-blue-500',
|
||||||
|
close: 'text-blue-400 hover:text-blue-600 dark:text-blue-300 dark:hover:text-blue-100',
|
||||||
|
progress: 'bg-blue-500',
|
||||||
|
action: 'bg-blue-500 hover:bg-blue-600 text-white'
|
||||||
|
};
|
||||||
|
case 'info':
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
bg: 'bg-blue-50 dark:bg-blue-900/20',
|
||||||
|
border: 'border-blue-200 dark:border-blue-800',
|
||||||
|
text: 'text-blue-800 dark:text-blue-200',
|
||||||
|
icon: 'text-blue-500',
|
||||||
|
close: 'text-blue-400 hover:text-blue-600 dark:text-blue-300 dark:hover:text-blue-100',
|
||||||
|
progress: 'bg-blue-500',
|
||||||
|
action: 'bg-blue-500 hover:bg-blue-600 text-white'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPositionStyles = () => {
|
||||||
|
switch (position) {
|
||||||
|
case 'top-left':
|
||||||
|
return 'top-4 left-4';
|
||||||
|
case 'top-center':
|
||||||
|
return 'top-4 left-1/2 -translate-x-1/2';
|
||||||
|
case 'top-right':
|
||||||
|
return 'top-4 right-4';
|
||||||
|
case 'bottom-left':
|
||||||
|
return 'bottom-4 left-4';
|
||||||
|
case 'bottom-center':
|
||||||
|
return 'bottom-4 left-1/2 -translate-x-1/2';
|
||||||
|
case 'bottom-right':
|
||||||
|
return 'bottom-4 right-4';
|
||||||
|
default:
|
||||||
|
return 'top-4 right-4';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = getStyles();
|
||||||
|
const positionStyles = getPositionStyles();
|
||||||
|
const progressPercentage = duration > 0 ? (remainingTime / duration) * 100 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isVisible && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -20, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: -20, scale: 0.9 }}
|
||||||
|
transition={{ duration: 0.3, ease: 'easeOut' }}
|
||||||
|
className={`fixed z-[9999] max-w-sm w-full ${positionStyles}`}
|
||||||
|
>
|
||||||
|
<div className={`${styles.bg} ${styles.border} border rounded-xl shadow-lg backdrop-blur-sm overflow-hidden`}>
|
||||||
|
<div className="flex items-start p-4">
|
||||||
|
<div className={`flex-shrink-0 ${styles.icon} mr-3 mt-0.5`}>
|
||||||
|
{getIcon()}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{title && (
|
||||||
|
<h4 className={`text-sm font-semibold ${styles.text} mb-1`}>
|
||||||
|
{title}
|
||||||
|
</h4>
|
||||||
|
)}
|
||||||
|
<p className={`text-sm ${styles.text}`}>
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
{action && (
|
||||||
|
<button
|
||||||
|
onClick={action.onClick}
|
||||||
|
className={`mt-2 px-3 py-1 text-xs font-medium rounded-md transition-colors ${styles.action}`}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{closable && (
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className={`flex-shrink-0 ml-3 ${styles.close} transition-colors`}
|
||||||
|
>
|
||||||
|
<XMarkIcon className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showProgress && duration > 0 && type !== 'loading' && (
|
||||||
|
<div className="h-1 bg-gray-200 dark:bg-gray-700 overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
className={`h-full ${styles.progress}`}
|
||||||
|
initial={{ width: '100%' }}
|
||||||
|
animate={{ width: `${progressPercentage}%` }}
|
||||||
|
transition={{ duration: 0.1, ease: 'linear' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全局消息管理器
|
||||||
|
class MessageManager {
|
||||||
|
private static instance: MessageManager;
|
||||||
|
private listeners: Array<(message: Message) => void> = [];
|
||||||
|
|
||||||
|
static getInstance(): MessageManager {
|
||||||
|
if (!MessageManager.instance) {
|
||||||
|
MessageManager.instance = new MessageManager();
|
||||||
|
}
|
||||||
|
return MessageManager.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 基础消息方法
|
||||||
|
show(message: string, options?: Omit<MessageNotificationProps, 'message'>) {
|
||||||
|
return this.addMessage(message, 'info', options);
|
||||||
|
}
|
||||||
|
|
||||||
|
success(message: string, options?: Omit<MessageNotificationProps, 'message'>) {
|
||||||
|
return this.addMessage(message, 'success', options);
|
||||||
|
}
|
||||||
|
|
||||||
|
error(message: string, options?: Omit<MessageNotificationProps, 'message'>) {
|
||||||
|
return this.addMessage(message, 'error', options);
|
||||||
|
}
|
||||||
|
|
||||||
|
warning(message: string, options?: Omit<MessageNotificationProps, 'message'>) {
|
||||||
|
return this.addMessage(message, 'warning', options);
|
||||||
|
}
|
||||||
|
|
||||||
|
info(message: string, options?: Omit<MessageNotificationProps, 'message'>) {
|
||||||
|
return this.addMessage(message, 'info', options);
|
||||||
|
}
|
||||||
|
|
||||||
|
loading(message: string, options?: Omit<MessageNotificationProps, 'message'>) {
|
||||||
|
return this.addMessage(message, 'loading', { ...options, duration: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 隐藏加载消息
|
||||||
|
hideLoading(id?: string) {
|
||||||
|
if (id) {
|
||||||
|
this.listeners.forEach(listener => listener({ id, message: '', type: 'loading', createdAt: Date.now() } as Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private addMessage(message: string, type: MessageType, options?: Omit<MessageNotificationProps, 'message'>) {
|
||||||
|
const id = Math.random().toString(36).substr(2, 9);
|
||||||
|
const notification: Message = {
|
||||||
|
id,
|
||||||
|
message,
|
||||||
|
type,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
this.listeners.forEach(listener => listener(notification));
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe(listener: (message: Message) => void) {
|
||||||
|
this.listeners.push(listener);
|
||||||
|
return () => {
|
||||||
|
this.listeners = this.listeners.filter(l => l !== listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const messageManager = MessageManager.getInstance();
|
||||||
|
|
||||||
|
// 消息提示容器组件
|
||||||
|
export function MessageNotificationContainer() {
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = messageManager.subscribe((message) => {
|
||||||
|
if (message.type === 'loading' && message.message === '') {
|
||||||
|
// 隐藏加载消息
|
||||||
|
setMessages(prev => prev.filter(m => m.id !== message.id));
|
||||||
|
} else {
|
||||||
|
setMessages(prev => [...prev, message]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeMessage = (id: string) => {
|
||||||
|
setMessages(prev => prev.filter(m => m.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 按位置分组消息
|
||||||
|
const messagesByPosition = messages.reduce((acc, message) => {
|
||||||
|
const position = message.position || 'top-right';
|
||||||
|
if (!acc[position]) {
|
||||||
|
acc[position] = [];
|
||||||
|
}
|
||||||
|
acc[position].push(message);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, Message[]>);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Object.entries(messagesByPosition).map(([position, positionMessages]) => (
|
||||||
|
<div key={position} className="fixed inset-0 pointer-events-none z-[9999]">
|
||||||
|
<div className={`absolute ${getPositionContainerStyles(position)} space-y-2 pointer-events-auto`}>
|
||||||
|
{positionMessages.map((message) => (
|
||||||
|
<MessageNotification
|
||||||
|
key={message.id}
|
||||||
|
{...message}
|
||||||
|
onClose={() => removeMessage(message.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPositionContainerStyles(position: string) {
|
||||||
|
switch (position) {
|
||||||
|
case 'top-left':
|
||||||
|
return 'top-4 left-4';
|
||||||
|
case 'top-center':
|
||||||
|
return 'top-4 left-1/2 -translate-x-1/2';
|
||||||
|
case 'top-right':
|
||||||
|
return 'top-4 right-4';
|
||||||
|
case 'bottom-left':
|
||||||
|
return 'bottom-4 left-4';
|
||||||
|
case 'bottom-center':
|
||||||
|
return 'bottom-4 left-1/2 -translate-x-1/2';
|
||||||
|
case 'bottom-right':
|
||||||
|
return 'bottom-4 right-4';
|
||||||
|
default:
|
||||||
|
return 'top-4 right-4';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容层:保持与ErrorNotification的兼容性
|
||||||
|
export { errorManager } from './ErrorNotification';
|
||||||
|
export type { ErrorType } from './ErrorNotification';
|
||||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useRef } from 'react';
|
|||||||
import Link from 'next/link';
|
import 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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
177
src/components/PageTransition.tsx
Normal file
177
src/components/PageTransition.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { usePathname, useSearchParams } from 'next/navigation';
|
||||||
|
import { useEffect, useState, useRef } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
interface PageTransitionProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PageTransition({ children }: PageTransitionProps) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const [isNavigating, setIsNavigating] = useState(false);
|
||||||
|
const [displayChildren, setDisplayChildren] = useState(children);
|
||||||
|
const [pendingChildren, setPendingChildren] = useState<React.ReactNode | null>(null);
|
||||||
|
const navigationTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
// 监听路由变化
|
||||||
|
useEffect(() => {
|
||||||
|
// 当 pathname 或 searchParams 变化时,表示路由发生了变化
|
||||||
|
if (children !== displayChildren) {
|
||||||
|
setPendingChildren(children);
|
||||||
|
setIsNavigating(true);
|
||||||
|
|
||||||
|
// 清除之前的超时
|
||||||
|
if (navigationTimeoutRef.current) {
|
||||||
|
clearTimeout(navigationTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模拟加载时间,让 exit 动画有足够时间执行
|
||||||
|
navigationTimeoutRef.current = setTimeout(() => {
|
||||||
|
setDisplayChildren(children);
|
||||||
|
setPendingChildren(null);
|
||||||
|
setIsNavigating(false);
|
||||||
|
}, 500); // 给 exit 动画 300ms + 缓冲时间
|
||||||
|
}
|
||||||
|
}, [pathname, searchParams, children, displayChildren]);
|
||||||
|
|
||||||
|
// 清理超时
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (navigationTimeoutRef.current) {
|
||||||
|
clearTimeout(navigationTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getPageVariants = (direction: 'left' | 'right' | 'up' | 'down' = 'right') => {
|
||||||
|
const directions = {
|
||||||
|
left: { x: -100, y: 0 },
|
||||||
|
right: { x: 100, y: 0 },
|
||||||
|
up: { x: 0, y: -100 },
|
||||||
|
down: { x: 0, y: 100 }
|
||||||
|
};
|
||||||
|
|
||||||
|
const exitDirections = {
|
||||||
|
left: { x: 100, y: 0 },
|
||||||
|
right: { x: -100, y: 0 },
|
||||||
|
up: { x: 0, y: 100 },
|
||||||
|
down: { x: 0, y: -100 }
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
initial: {
|
||||||
|
opacity: 0,
|
||||||
|
...directions[direction],
|
||||||
|
scale: 0.9,
|
||||||
|
rotateX: -15
|
||||||
|
},
|
||||||
|
animate: {
|
||||||
|
opacity: 1,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
scale: 1,
|
||||||
|
rotateX: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.5,
|
||||||
|
ease: [0.25, 0.46, 0.45, 0.94],
|
||||||
|
type: "spring",
|
||||||
|
stiffness: 100,
|
||||||
|
damping: 15
|
||||||
|
}
|
||||||
|
},
|
||||||
|
exit: {
|
||||||
|
opacity: 0,
|
||||||
|
...exitDirections[direction],
|
||||||
|
scale: 0.9,
|
||||||
|
rotateX: 15,
|
||||||
|
transition: {
|
||||||
|
duration: 0.3,
|
||||||
|
ease: "easeIn"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLoadingVariants = () => ({
|
||||||
|
initial: {
|
||||||
|
opacity: 0,
|
||||||
|
scale: 0.8,
|
||||||
|
y: 20
|
||||||
|
},
|
||||||
|
animate: {
|
||||||
|
opacity: 1,
|
||||||
|
scale: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.3,
|
||||||
|
ease: "easeOut"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
exit: {
|
||||||
|
opacity: 0,
|
||||||
|
scale: 0.8,
|
||||||
|
y: -20,
|
||||||
|
transition: {
|
||||||
|
duration: 0.2,
|
||||||
|
ease: "easeIn"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{isNavigating && (
|
||||||
|
<motion.div
|
||||||
|
key="loading"
|
||||||
|
variants={getLoadingVariants()}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm pointer-events-none"
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<motion.div
|
||||||
|
animate={{
|
||||||
|
rotate: 360,
|
||||||
|
scale: [1, 1.1, 1]
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
rotate: { duration: 1, repeat: Infinity, ease: "linear" },
|
||||||
|
scale: { duration: 1.5, repeat: Infinity, ease: "easeInOut" }
|
||||||
|
}}
|
||||||
|
className="w-12 h-12 border-4 border-orange-500 border-t-transparent rounded-full mx-auto mb-4"
|
||||||
|
/>
|
||||||
|
<motion.p
|
||||||
|
className="text-lg font-medium text-gray-700 dark:text-gray-300"
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.2 }}
|
||||||
|
>
|
||||||
|
页面切换中...
|
||||||
|
</motion.p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key={pathname + searchParams.toString()}
|
||||||
|
variants={getPageVariants()}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
exit="exit"
|
||||||
|
className="min-h-screen"
|
||||||
|
>
|
||||||
|
{displayChildren}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -41,7 +41,7 @@ export default function ScrollToTop() {
|
|||||||
exit={{ opacity: 0, scale: 0.8, y: 20 }}
|
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
446
src/components/SkinCard.tsx
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { EyeIcon, ArrowDownTrayIcon, HeartIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { HeartIcon as HeartIconSolid } from '@heroicons/react/24/solid';
|
||||||
|
import SkinViewer from './SkinViewer';
|
||||||
|
import type { Texture } from '@/lib/api';
|
||||||
|
|
||||||
|
interface SkinCardProps {
|
||||||
|
texture: Texture;
|
||||||
|
isFavorited?: boolean;
|
||||||
|
onViewDetails: (texture: Texture) => void;
|
||||||
|
onToggleFavorite?: (textureId: number) => void;
|
||||||
|
onDownload?: (texture: Texture) => void;
|
||||||
|
showVisibilityBadge?: boolean;
|
||||||
|
showActions?: boolean;
|
||||||
|
customActions?: React.ReactNode;
|
||||||
|
index?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SkinCard({
|
||||||
|
texture,
|
||||||
|
isFavorited = false,
|
||||||
|
onViewDetails,
|
||||||
|
onToggleFavorite,
|
||||||
|
onDownload,
|
||||||
|
showVisibilityBadge = true,
|
||||||
|
showActions = true,
|
||||||
|
customActions,
|
||||||
|
index = 0
|
||||||
|
}: SkinCardProps) {
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const [imageLoaded, setImageLoaded] = useState(false);
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
const [isFavoriting, setIsFavoriting] = useState(false);
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
if (isDownloading) return;
|
||||||
|
|
||||||
|
setIsDownloading(true);
|
||||||
|
|
||||||
|
// 模拟下载延迟
|
||||||
|
setTimeout(() => {
|
||||||
|
if (onDownload) {
|
||||||
|
onDownload(texture);
|
||||||
|
} else {
|
||||||
|
window.open(texture.url, '_blank');
|
||||||
|
}
|
||||||
|
setIsDownloading(false);
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleFavorite = async () => {
|
||||||
|
if (isFavoriting || !onToggleFavorite) return;
|
||||||
|
|
||||||
|
setIsFavoriting(true);
|
||||||
|
|
||||||
|
// 模拟收藏操作延迟
|
||||||
|
setTimeout(() => {
|
||||||
|
onToggleFavorite(texture.id);
|
||||||
|
setIsFavoriting(false);
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCardVariants = () => ({
|
||||||
|
hidden: {
|
||||||
|
opacity: 0,
|
||||||
|
y: 50,
|
||||||
|
scale: 0.9,
|
||||||
|
rotateX: -15
|
||||||
|
},
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
scale: 1,
|
||||||
|
rotateX: 0,
|
||||||
|
transition: {
|
||||||
|
duration: 0.6,
|
||||||
|
delay: index * 0.1,
|
||||||
|
ease: [0.25, 0.46, 0.45, 0.94],
|
||||||
|
type: "spring",
|
||||||
|
stiffness: 100,
|
||||||
|
damping: 15
|
||||||
|
}
|
||||||
|
},
|
||||||
|
hover: {
|
||||||
|
scale: 1.03,
|
||||||
|
y: -8,
|
||||||
|
rotateX: 5,
|
||||||
|
transition: {
|
||||||
|
duration: 0.3,
|
||||||
|
ease: "easeOut"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const getActionButtonVariants = () => ({
|
||||||
|
initial: { scale: 0, opacity: 0 },
|
||||||
|
hover: {
|
||||||
|
scale: 1,
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
duration: 0.2,
|
||||||
|
delay: 0.1,
|
||||||
|
type: "spring",
|
||||||
|
stiffness: 300,
|
||||||
|
damping: 20
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tap: {
|
||||||
|
scale: 0.9,
|
||||||
|
transition: { duration: 0.1 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const getTagVariants = () => ({
|
||||||
|
initial: { scale: 0.8, opacity: 0 },
|
||||||
|
animate: {
|
||||||
|
scale: 1,
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
duration: 0.3,
|
||||||
|
delay: 0.2 + index * 0.05,
|
||||||
|
type: "spring",
|
||||||
|
stiffness: 200,
|
||||||
|
damping: 15
|
||||||
|
}
|
||||||
|
},
|
||||||
|
hover: {
|
||||||
|
scale: 1.05,
|
||||||
|
transition: { duration: 0.2 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
variants={getCardVariants()}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
whileHover="hover"
|
||||||
|
onHoverStart={() => setIsHovered(true)}
|
||||||
|
onHoverEnd={() => setIsHovered(false)}
|
||||||
|
className="group relative bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm rounded-xl shadow-lg hover:shadow-2xl transition-all duration-300 overflow-hidden border border-white/20 dark:border-gray-700/30"
|
||||||
|
style={{
|
||||||
|
perspective: '1000px',
|
||||||
|
transformStyle: 'preserve-3d'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 3D预览区域 */}
|
||||||
|
<div className="relative aspect-square bg-gradient-to-br from-orange-50 to-amber-50 dark:from-gray-700 dark:to-gray-600 overflow-hidden">
|
||||||
|
{/* 加载状态 */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{!imageLoaded && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-orange-100 to-amber-100 dark:from-gray-600 dark:to-gray-500"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
animate={{
|
||||||
|
rotate: 360,
|
||||||
|
scale: [1, 1.1, 1]
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
rotate: { duration: 2, repeat: Infinity, ease: "linear" },
|
||||||
|
scale: { duration: 1.5, repeat: Infinity, ease: "easeInOut" }
|
||||||
|
}}
|
||||||
|
className="w-12 h-12 border-4 border-orange-300 dark:border-orange-600 border-t-transparent rounded-full"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{texture.type === 'SKIN' ? (
|
||||||
|
<SkinViewer
|
||||||
|
skinUrl={texture.url}
|
||||||
|
isSlim={texture.is_slim}
|
||||||
|
width={300}
|
||||||
|
height={300}
|
||||||
|
className={`w-full h-full transition-all duration-500 ${
|
||||||
|
imageLoaded ? 'opacity-100 scale-100' : 'opacity-0 scale-95'
|
||||||
|
} ${isHovered ? 'scale-110' : ''}`}
|
||||||
|
autoRotate={isHovered}
|
||||||
|
walking={false}
|
||||||
|
onLoad={() => setImageLoaded(true)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<motion.div
|
||||||
|
className="text-center"
|
||||||
|
animate={isHovered ? { y: [-5, 5, -5] } : {}}
|
||||||
|
transition={{ duration: 2, repeat: Infinity, ease: "easeInOut" }}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="w-24 h-24 mx-auto mb-2 bg-white dark:bg-gray-800 rounded-xl shadow-lg flex items-center justify-center"
|
||||||
|
whileHover={{ scale: 1.1, rotate: 10 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 300 }}
|
||||||
|
animate={imageLoaded ? {} : { scale: [0.8, 1, 0.8] }}
|
||||||
|
transition={imageLoaded ? {} : { duration: 1.5, repeat: Infinity }}
|
||||||
|
>
|
||||||
|
<span className="text-2xl">🧥</span>
|
||||||
|
</motion.div>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-300 font-medium">披风</p>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 悬停操作按钮 */}
|
||||||
|
{showActions && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: isHovered ? 1 : 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="absolute inset-0 bg-gradient-to-br from-black/40 via-black/30 to-transparent flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<motion.button
|
||||||
|
variants={getActionButtonVariants()}
|
||||||
|
initial="initial"
|
||||||
|
animate={isHovered ? "hover" : "initial"}
|
||||||
|
whileTap="tap"
|
||||||
|
onClick={() => onViewDetails(texture)}
|
||||||
|
className="bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white p-3 rounded-full shadow-lg transition-all duration-200 backdrop-blur-sm"
|
||||||
|
title="详细预览"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
animate={{ scale: [1, 1.1, 1] }}
|
||||||
|
transition={{ duration: 2, repeat: Infinity }}
|
||||||
|
>
|
||||||
|
<EyeIcon className="w-5 h-5" />
|
||||||
|
</motion.div>
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
{onDownload !== false && (
|
||||||
|
<motion.button
|
||||||
|
variants={getActionButtonVariants()}
|
||||||
|
initial="initial"
|
||||||
|
animate={isHovered ? "hover" : "initial"}
|
||||||
|
whileTap="tap"
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={isDownloading}
|
||||||
|
className={`p-3 rounded-full shadow-lg transition-all duration-200 backdrop-blur-sm ${
|
||||||
|
isDownloading
|
||||||
|
? 'bg-gray-500 cursor-not-allowed'
|
||||||
|
: 'bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white'
|
||||||
|
}`}
|
||||||
|
title={isDownloading ? '下载中...' : '查看原图'}
|
||||||
|
>
|
||||||
|
{isDownloading ? (
|
||||||
|
<motion.div
|
||||||
|
animate={{ rotate: 360 }}
|
||||||
|
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||||
|
>
|
||||||
|
<ArrowDownTrayIcon className="w-5 h-5" />
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<ArrowDownTrayIcon className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onToggleFavorite && (
|
||||||
|
<motion.button
|
||||||
|
variants={getActionButtonVariants()}
|
||||||
|
initial="initial"
|
||||||
|
animate={isHovered ? "hover" : "initial"}
|
||||||
|
whileTap="tap"
|
||||||
|
onClick={handleToggleFavorite}
|
||||||
|
disabled={isFavoriting}
|
||||||
|
className={`p-3 rounded-full shadow-lg transition-all duration-200 backdrop-blur-sm ${
|
||||||
|
isFavoriting
|
||||||
|
? 'cursor-not-allowed opacity-75'
|
||||||
|
: isFavorited
|
||||||
|
? 'bg-gradient-to-r from-red-500 to-pink-500 hover:from-red-600 hover:to-pink-600 text-white'
|
||||||
|
: 'bg-gradient-to-r from-gray-500 to-gray-600 hover:from-gray-600 hover:to-gray-700 text-white'
|
||||||
|
}`}
|
||||||
|
title={isFavorited ? '取消收藏' : '添加收藏'}
|
||||||
|
>
|
||||||
|
{isFavoriting ? (
|
||||||
|
<motion.div
|
||||||
|
animate={{ scale: [1, 1.2, 1] }}
|
||||||
|
transition={{ duration: 0.5, repeat: Infinity }}
|
||||||
|
>
|
||||||
|
<HeartIcon className="w-5 h-5" />
|
||||||
|
</motion.div>
|
||||||
|
) : isFavorited ? (
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ type: "spring", stiffness: 300 }}
|
||||||
|
>
|
||||||
|
<HeartIconSolid className="w-5 h-5" />
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<HeartIcon className="w-5 h-5" />
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 标签 */}
|
||||||
|
<div className="absolute top-3 right-3 flex gap-1.5">
|
||||||
|
<motion.span
|
||||||
|
variants={getTagVariants()}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
whileHover="hover"
|
||||||
|
className={`px-2 py-1 text-white text-xs rounded-full font-medium backdrop-blur-sm shadow-lg ${
|
||||||
|
texture.type === 'SKIN' ? 'bg-blue-500/80' : 'bg-purple-500/80'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{texture.type === 'SKIN' ? '皮肤' : '披风'}
|
||||||
|
</motion.span>
|
||||||
|
{texture.is_slim && (
|
||||||
|
<motion.span
|
||||||
|
variants={getTagVariants()}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
whileHover="hover"
|
||||||
|
className="px-2 py-1 bg-pink-500/80 text-white text-xs rounded-full font-medium backdrop-blur-sm shadow-lg"
|
||||||
|
>
|
||||||
|
细臂
|
||||||
|
</motion.span>
|
||||||
|
)}
|
||||||
|
{showVisibilityBadge && !texture.is_public && (
|
||||||
|
<motion.span
|
||||||
|
variants={getTagVariants()}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
whileHover="hover"
|
||||||
|
className="px-2 py-1 bg-gray-800/80 text-white text-xs rounded-full font-medium backdrop-blur-sm shadow-lg"
|
||||||
|
>
|
||||||
|
私密
|
||||||
|
</motion.span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 悬停时的光效 */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isHovered && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="absolute inset-0 bg-gradient-to-br from-orange-400/10 via-transparent to-amber-400/10 pointer-events-none"
|
||||||
|
style={{
|
||||||
|
background: `radial-gradient(circle at ${50}% ${50}%, rgba(249, 115, 22, 0.1) 0%, transparent 70%)`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Texture Info */}
|
||||||
|
<div className="p-4">
|
||||||
|
<motion.h3
|
||||||
|
className="font-semibold text-gray-900 dark:text-white mb-1 truncate"
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: index * 0.1 + 0.3 }}
|
||||||
|
>
|
||||||
|
{texture.name}
|
||||||
|
</motion.h3>
|
||||||
|
{texture.description && (
|
||||||
|
<motion.p
|
||||||
|
className="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2 leading-relaxed"
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: index * 0.1 + 0.4 }}
|
||||||
|
>
|
||||||
|
{texture.description}
|
||||||
|
</motion.p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<motion.div
|
||||||
|
className="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400 mb-4"
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: index * 0.1 + 0.5 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
{onToggleFavorite && (
|
||||||
|
<motion.span
|
||||||
|
className="flex items-center space-x-1"
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
animate={isFavorited ? { scale: [1, 1.2, 1] } : {}}
|
||||||
|
transition={isFavorited ? { duration: 0.3 } : {}}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
animate={isFavorited ? { scale: [1, 1.2, 1] } : {}}
|
||||||
|
transition={isFavorited ? { duration: 0.5 } : {}}
|
||||||
|
>
|
||||||
|
<HeartIcon className="w-4 h-4 text-red-400" />
|
||||||
|
</motion.div>
|
||||||
|
<span className="font-medium">{texture.favorite_count || 0}</span>
|
||||||
|
</motion.span>
|
||||||
|
)}
|
||||||
|
<motion.span
|
||||||
|
className="flex items-center space-x-1"
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
>
|
||||||
|
<ArrowDownTrayIcon className="w-4 h-4 text-blue-400" />
|
||||||
|
<span className="font-medium">{texture.download_count || 0}</span>
|
||||||
|
</motion.span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
{texture.uploader && (
|
||||||
|
<motion.span
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ delay: index * 0.1 + 0.6 }}
|
||||||
|
>
|
||||||
|
by {texture.uploader.username}
|
||||||
|
</motion.span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Custom Actions */}
|
||||||
|
{customActions && (
|
||||||
|
<motion.div
|
||||||
|
className="flex gap-2"
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: index * 0.1 + 0.6 }}
|
||||||
|
>
|
||||||
|
{customActions}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部装饰条 */}
|
||||||
|
<motion.div
|
||||||
|
className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-orange-400 to-amber-500"
|
||||||
|
initial={{ scaleX: 0 }}
|
||||||
|
animate={{ scaleX: 1 }}
|
||||||
|
transition={{ delay: index * 0.1 + 0.7, duration: 0.5 }}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
'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';
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
154
src/components/profile/CharacterCard.tsx
Normal file
154
src/components/profile/CharacterCard.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { UserIcon, PencilIcon, TrashIcon, CheckIcon } from '@heroicons/react/24/outline';
|
||||||
|
import SkinViewer from '@/components/SkinViewer';
|
||||||
|
import type { Profile } from '@/lib/api';
|
||||||
|
|
||||||
|
interface CharacterCardProps {
|
||||||
|
profile: Profile;
|
||||||
|
skinUrl?: string;
|
||||||
|
isSlim?: boolean;
|
||||||
|
isEditing?: boolean;
|
||||||
|
editName?: string;
|
||||||
|
onEdit: (uuid: string, currentName: string) => void;
|
||||||
|
onSave: (uuid: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
onDelete: (uuid: string) => void;
|
||||||
|
onSetActive: (uuid: string) => void;
|
||||||
|
onSelectSkin: (uuid: string) => void;
|
||||||
|
onEditNameChange: (name: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CharacterCard({
|
||||||
|
profile,
|
||||||
|
skinUrl,
|
||||||
|
isSlim,
|
||||||
|
isEditing,
|
||||||
|
editName,
|
||||||
|
onEdit,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
onDelete,
|
||||||
|
onSetActive,
|
||||||
|
onSelectSkin,
|
||||||
|
onEditNameChange
|
||||||
|
}: CharacterCardProps) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={profile.uuid}
|
||||||
|
className="bg-white/50 dark:bg-gray-800/50 backdrop-blur-lg rounded-2xl p-6 border border-white/20 dark:border-gray-700/50 shadow-lg"
|
||||||
|
whileHover={{ scale: 1.02, y: -5 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
{isEditing ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editName}
|
||||||
|
onChange={(e) => onEditNameChange(e.target.value)}
|
||||||
|
className="text-lg font-semibold bg-transparent border-b border-orange-500 focus:outline-none text-gray-900 dark:text-white flex-1 mr-2"
|
||||||
|
onBlur={() => onSave(profile.uuid)}
|
||||||
|
onKeyPress={(e) => e.key === 'Enter' && onSave(profile.uuid)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white truncate flex-1">{profile.name}</h3>
|
||||||
|
)}
|
||||||
|
{profile.is_active && (
|
||||||
|
<span className="px-2 py-1 bg-gradient-to-r from-green-500 to-emerald-500 text-white text-xs rounded-full flex items-center space-x-1">
|
||||||
|
<CheckIcon className="w-3 h-3" />
|
||||||
|
<span>当前使用</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="aspect-square bg-gradient-to-br from-orange-100 to-amber-100 dark:from-gray-700 dark:to-gray-600 rounded-xl mb-4 flex items-center justify-center relative overflow-hidden">
|
||||||
|
{skinUrl ? (
|
||||||
|
<SkinViewer
|
||||||
|
skinUrl={skinUrl}
|
||||||
|
isSlim={isSlim}
|
||||||
|
width={200}
|
||||||
|
height={200}
|
||||||
|
className="w-full h-full"
|
||||||
|
autoRotate={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
className="w-20 h-20 bg-gradient-to-br from-orange-400 to-amber-500 rounded-lg shadow-lg flex items-center justify-center"
|
||||||
|
whileHover={{ scale: 1.1, rotate: 5 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 300 }}
|
||||||
|
>
|
||||||
|
<UserIcon className="w-10 h-10 text-white" />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 皮肤选择按钮 */}
|
||||||
|
<motion.button
|
||||||
|
onClick={() => onSelectSkin(profile.uuid)}
|
||||||
|
className="absolute bottom-2 right-2 bg-gradient-to-r from-orange-500 to-amber-500 text-white p-2 rounded-full shadow-lg"
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
whileTap={{ scale: 0.9 }}
|
||||||
|
title="选择皮肤"
|
||||||
|
>
|
||||||
|
<PencilIcon className="w-4 h-4" />
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{!profile.is_active && (
|
||||||
|
<motion.button
|
||||||
|
onClick={() => onSetActive(profile.uuid)}
|
||||||
|
className="flex-1 bg-gradient-to-r from-green-500 to-emerald-500 hover:from-green-600 hover:to-emerald-600 text-white text-sm py-2 px-3 rounded-lg transition-all duration-200"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
使用
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<motion.button
|
||||||
|
onClick={() => onSave(profile.uuid)}
|
||||||
|
className="flex-1 bg-gradient-to-r from-green-500 to-emerald-500 hover:from-green-600 hover:to-emerald-600 text-white text-sm py-2 px-3 rounded-lg transition-all duration-200"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</motion.button>
|
||||||
|
<motion.button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="flex-1 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 text-sm py-2 px-3 rounded-lg transition-all duration-200"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</motion.button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<motion.button
|
||||||
|
onClick={() => onEdit(profile.uuid, profile.name)}
|
||||||
|
className="flex-1 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 text-sm py-2 px-3 rounded-lg transition-all duration-200"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
<PencilIcon className="w-4 h-4 inline mr-1" />
|
||||||
|
编辑
|
||||||
|
</motion.button>
|
||||||
|
<motion.button
|
||||||
|
onClick={() => onDelete(profile.uuid)}
|
||||||
|
className="px-3 py-2 border border-red-500 text-red-500 hover:bg-red-500 hover:text-white rounded-lg transition-all duration-200"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
<TrashIcon className="w-4 h-4" />
|
||||||
|
</motion.button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
src/components/profile/FavoritesTab.tsx
Normal file
76
src/components/profile/FavoritesTab.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { PhotoIcon, ArrowDownTrayIcon, EyeIcon, HeartIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { HeartIcon as HeartIconSolid } from '@heroicons/react/24/solid';
|
||||||
|
import SkinCard from '@/components/SkinCard';
|
||||||
|
import SkinDetailModal from '@/components/SkinDetailModal';
|
||||||
|
import type { Texture } from '@/lib/api';
|
||||||
|
|
||||||
|
interface FavoritesTabProps {
|
||||||
|
skins: Texture[];
|
||||||
|
onToggleFavorite: (skinId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FavoritesTab({ skins, onToggleFavorite }: FavoritesTabProps) {
|
||||||
|
const [selectedSkin, setSelectedSkin] = useState<Texture | null>(null);
|
||||||
|
const [showSkinDetail, setShowSkinDetail] = useState(false);
|
||||||
|
|
||||||
|
const handleViewDetails = (skin: Texture) => {
|
||||||
|
setSelectedSkin(skin);
|
||||||
|
setShowSkinDetail(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">收藏夹</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{skins.length === 0 ? (
|
||||||
|
<div className="bg-white/50 dark:bg-gray-800/50 backdrop-blur-lg rounded-2xl p-12 text-center">
|
||||||
|
<HeartIcon className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">暂无收藏</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">去发现一些喜欢的皮肤吧!</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
|
{skins.map((skin) => (
|
||||||
|
<SkinCard
|
||||||
|
key={skin.id}
|
||||||
|
texture={skin}
|
||||||
|
isFavorited={true}
|
||||||
|
onViewDetails={handleViewDetails}
|
||||||
|
onToggleFavorite={onToggleFavorite}
|
||||||
|
customActions={
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleFavorite(skin.id);
|
||||||
|
}}
|
||||||
|
className="flex-1 px-3 py-2 border border-red-500 text-red-500 hover:bg-red-500 hover:text-white rounded-lg transition-all duration-200"
|
||||||
|
>
|
||||||
|
取消收藏
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 皮肤详情模态框 */}
|
||||||
|
<SkinDetailModal
|
||||||
|
isOpen={showSkinDetail}
|
||||||
|
onClose={() => setShowSkinDetail(false)}
|
||||||
|
texture={selectedSkin}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
src/components/profile/MySkinsTab.tsx
Normal file
107
src/components/profile/MySkinsTab.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
PhotoIcon,
|
||||||
|
ArrowDownTrayIcon,
|
||||||
|
EyeIcon,
|
||||||
|
TrashIcon,
|
||||||
|
CloudArrowUpIcon
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import SkinCard from '@/components/SkinCard';
|
||||||
|
import SkinDetailModal from '@/components/SkinDetailModal';
|
||||||
|
import type { Texture } from '@/lib/api';
|
||||||
|
|
||||||
|
interface MySkinsTabProps {
|
||||||
|
skins: Texture[];
|
||||||
|
onUploadClick: () => void;
|
||||||
|
onToggleVisibility: (skinId: number) => void;
|
||||||
|
onDelete: (skinId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MySkinsTab({
|
||||||
|
skins,
|
||||||
|
onUploadClick,
|
||||||
|
onToggleVisibility,
|
||||||
|
onDelete
|
||||||
|
}: MySkinsTabProps) {
|
||||||
|
const [selectedSkin, setSelectedSkin] = useState<Texture | null>(null);
|
||||||
|
const [showSkinDetail, setShowSkinDetail] = useState(false);
|
||||||
|
|
||||||
|
const handleViewDetails = (skin: Texture) => {
|
||||||
|
setSelectedSkin(skin);
|
||||||
|
setShowSkinDetail(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -20 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="space-y-6"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">我的皮肤</h1>
|
||||||
|
<motion.button
|
||||||
|
onClick={onUploadClick}
|
||||||
|
className="bg-gradient-to-r from-orange-500 to-amber-500 text-white px-4 py-2 rounded-xl flex items-center space-x-2 shadow-lg hover:shadow-xl transition-all duration-200"
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
>
|
||||||
|
<CloudArrowUpIcon className="w-5 h-5" />
|
||||||
|
<span>上传皮肤</span>
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{skins.length === 0 ? (
|
||||||
|
<div className="bg-white/50 dark:bg-gray-800/50 backdrop-blur-lg rounded-2xl p-12 text-center">
|
||||||
|
<PhotoIcon className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">暂无皮肤</h3>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">上传你的第一个Minecraft皮肤吧!</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||||
|
{skins.map((skin) => (
|
||||||
|
<SkinCard
|
||||||
|
key={skin.id}
|
||||||
|
texture={skin}
|
||||||
|
onViewDetails={handleViewDetails}
|
||||||
|
onToggleVisibility={onToggleVisibility}
|
||||||
|
customActions={
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleVisibility(skin.id);
|
||||||
|
}}
|
||||||
|
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-lg transition-all duration-200 text-sm"
|
||||||
|
>
|
||||||
|
{skin.is_public ? '隐藏' : '公开'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(skin.id);
|
||||||
|
}}
|
||||||
|
className="px-3 py-2 border border-red-500 text-red-500 hover:bg-red-500 hover:text-white rounded-lg transition-all duration-200"
|
||||||
|
>
|
||||||
|
<TrashIcon className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 皮肤详情模态框 */}
|
||||||
|
<SkinDetailModal
|
||||||
|
isOpen={showSkinDetail}
|
||||||
|
onClose={() => setShowSkinDetail(false)}
|
||||||
|
texture={selectedSkin}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
src/components/profile/ProfileSidebar.tsx
Normal file
79
src/components/profile/ProfileSidebar.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
UserIcon,
|
||||||
|
PhotoIcon,
|
||||||
|
HeartIcon,
|
||||||
|
Cog6ToothIcon,
|
||||||
|
ArrowLeftOnRectangleIcon
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
interface ProfileSidebarProps {
|
||||||
|
activeTab: string;
|
||||||
|
onTabChange: (tab: string) => void;
|
||||||
|
skinCount: number;
|
||||||
|
favoriteCount: number;
|
||||||
|
profilesCount: number;
|
||||||
|
onLogout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProfileSidebar({
|
||||||
|
activeTab,
|
||||||
|
onTabChange,
|
||||||
|
skinCount,
|
||||||
|
favoriteCount,
|
||||||
|
profilesCount,
|
||||||
|
onLogout
|
||||||
|
}: ProfileSidebarProps) {
|
||||||
|
const menuItems = [
|
||||||
|
{ id: 'characters', name: '角色管理', icon: UserIcon, count: profilesCount },
|
||||||
|
{ id: 'skins', name: '我的皮肤', icon: PhotoIcon, count: skinCount },
|
||||||
|
{ id: 'favorites', name: '收藏夹', icon: HeartIcon, count: favoriteCount },
|
||||||
|
{ id: 'settings', name: '账户设置', icon: Cog6ToothIcon },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1">
|
||||||
|
<nav className="space-y-2">
|
||||||
|
{menuItems.map((item) => (
|
||||||
|
<motion.button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => onTabChange(item.id)}
|
||||||
|
className={`w-full flex items-center justify-between p-3 rounded-xl transition-all duration-200 ${
|
||||||
|
activeTab === item.id
|
||||||
|
? 'bg-gradient-to-r from-orange-500 to-amber-500 text-white shadow-lg'
|
||||||
|
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<item.icon className="w-5 h-5" />
|
||||||
|
<span className="font-medium">{item.name}</span>
|
||||||
|
</div>
|
||||||
|
{item.count !== undefined && (
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs ${
|
||||||
|
activeTab === item.id
|
||||||
|
? 'bg-white/20 text-white'
|
||||||
|
: 'bg-gray-200 dark:bg-gray-600 text-gray-600 dark:text-gray-400'
|
||||||
|
}`}>
|
||||||
|
{item.count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
onClick={onLogout}
|
||||||
|
className="w-full flex items-center justify-center space-x-2 p-3 border border-red-500 text-red-500 hover:bg-red-500 hover:text-white rounded-xl transition-all duration-200 mt-6"
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
<ArrowLeftOnRectangleIcon className="w-5 h-5" />
|
||||||
|
<span>退出登录</span>
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
199
src/components/profile/UploadSkinModal.tsx
Normal file
199
src/components/profile/UploadSkinModal.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { XMarkIcon, CloudArrowUpIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { messageManager } from '@/components/MessageNotification';
|
||||||
|
|
||||||
|
interface UploadSkinModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onUpload: (file: File, data: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
type: 'SKIN' | 'CAPE';
|
||||||
|
is_public: boolean;
|
||||||
|
is_slim: boolean;
|
||||||
|
}) => Promise<void>;
|
||||||
|
isUploading?: boolean;
|
||||||
|
uploadProgress?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UploadSkinModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onUpload,
|
||||||
|
isUploading = false,
|
||||||
|
uploadProgress = 0
|
||||||
|
}: UploadSkinModalProps) {
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
|
const [skinData, setSkinData] = useState({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
type: 'SKIN' as 'SKIN' | 'CAPE',
|
||||||
|
is_public: false,
|
||||||
|
is_slim: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
setSelectedFile(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!selectedFile || !skinData.name.trim()) {
|
||||||
|
messageManager.warning('请选择皮肤文件并输入皮肤名称', { duration: 3000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await onUpload(selectedFile, skinData);
|
||||||
|
// 重置表单
|
||||||
|
setSelectedFile(null);
|
||||||
|
setSkinData({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
type: 'SKIN',
|
||||||
|
is_public: false,
|
||||||
|
is_slim: false
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-[9999]"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.9, y: 20 }}
|
||||||
|
animate={{ scale: 1, y: 0 }}
|
||||||
|
exit={{ scale: 0.9, y: 20 }}
|
||||||
|
className="bg-white dark:bg-gray-800 rounded-2xl p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="text-xl font-bold text-gray-900 dark:text-white">上传皮肤</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<XMarkIcon className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
皮肤文件
|
||||||
|
</label>
|
||||||
|
<div className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl p-6 text-center hover:border-orange-500 transition-colors">
|
||||||
|
<CloudArrowUpIcon className="w-12 h-12 text-gray-400 mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||||
|
点击选择文件或拖拽到此处
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".png"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
className="hidden"
|
||||||
|
id="skin-upload"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="skin-upload"
|
||||||
|
className="cursor-pointer bg-gradient-to-r from-orange-500 to-amber-500 text-white px-4 py-2 rounded-lg hover:from-orange-600 hover:to-amber-600 transition-all"
|
||||||
|
>
|
||||||
|
选择文件
|
||||||
|
</label>
|
||||||
|
{selectedFile && (
|
||||||
|
<p className="text-sm text-green-600 dark:text-green-400 mt-2">
|
||||||
|
已选择: {selectedFile.name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
皮肤名称
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={skinData.name}
|
||||||
|
onChange={(e) => setSkinData(prev => ({ ...prev, name: e.target.value }))}
|
||||||
|
className="w-full px-4 py-3 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||||
|
placeholder="请输入皮肤名称"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
描述
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={skinData.description}
|
||||||
|
onChange={(e) => setSkinData(prev => ({ ...prev, description: e.target.value }))}
|
||||||
|
className="w-full px-4 py-3 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-orange-500 focus:border-transparent"
|
||||||
|
placeholder="请输入皮肤描述(可选)"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={skinData.is_public}
|
||||||
|
onChange={(e) => setSkinData(prev => ({ ...prev, is_public: e.target.checked }))}
|
||||||
|
className="w-4 h-4 text-orange-500 rounded focus:ring-orange-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">公开</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={skinData.is_slim}
|
||||||
|
onChange={(e) => setSkinData(prev => ({ ...prev, is_slim: e.target.checked }))}
|
||||||
|
className="w-4 h-4 text-orange-500 rounded focus:ring-orange-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700 dark:text-gray-300">细臂模型</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isUploading && (
|
||||||
|
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-gradient-to-r from-orange-500 to-amber-500 h-2 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${uploadProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex space-x-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!selectedFile || !skinData.name.trim() || isUploading}
|
||||||
|
className="flex-1 px-4 py-2 bg-gradient-to-r from-orange-500 to-amber-500 text-white rounded-xl hover:from-orange-600 hover:to-amber-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||||
|
>
|
||||||
|
{isUploading ? `上传中... ${uploadProgress}%` : '上传'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
src/components/profile/UserProfileCard.tsx
Normal file
89
src/components/profile/UserProfileCard.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { UserCircleIcon } from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
|
interface UserProfileCardProps {
|
||||||
|
username?: string;
|
||||||
|
email?: string;
|
||||||
|
avatar?: string;
|
||||||
|
skinCount: number;
|
||||||
|
favoriteCount: number;
|
||||||
|
points: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserProfileCard({
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
avatar,
|
||||||
|
skinCount,
|
||||||
|
favoriteCount,
|
||||||
|
points
|
||||||
|
}: UserProfileCardProps) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: -20, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
transition={{ duration: 0.5, ease: "easeOut" }}
|
||||||
|
className="bg-gradient-to-br from-orange-400 via-orange-500 to-amber-500 rounded-2xl p-6 mb-6 text-white shadow-xl relative overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* 装饰性背景元素 */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-white/10 to-transparent rounded-2xl"></div>
|
||||||
|
<div className="absolute top-0 right-0 w-20 h-20 bg-white/5 rounded-full -translate-y-8 translate-x-8"></div>
|
||||||
|
<div className="absolute bottom-0 left-0 w-16 h-16 bg-white/5 rounded-full translate-y-6 -translate-x-6"></div>
|
||||||
|
|
||||||
|
<div className="relative z-10">
|
||||||
|
<div className="flex items-center space-x-4 mb-4">
|
||||||
|
{avatar ? (
|
||||||
|
<motion.img
|
||||||
|
src={avatar}
|
||||||
|
alt={username}
|
||||||
|
className="w-16 h-16 rounded-full border-3 border-white/30 shadow-lg object-cover"
|
||||||
|
whileHover={{ scale: 1.05, rotate: 5 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
className="w-16 h-16 bg-white/20 rounded-full flex items-center justify-center border-2 border-white/30"
|
||||||
|
whileHover={{ scale: 1.05, rotate: 5 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<UserCircleIcon className="w-8 h-8 text-white" />
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h2 className="text-xl font-bold truncate">{username || '用户名'}</h2>
|
||||||
|
<p className="text-white/80 text-sm truncate">{email || '邮箱地址'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<motion.div
|
||||||
|
className="bg-white/10 rounded-lg p-3 text-center backdrop-blur-sm"
|
||||||
|
whileHover={{ scale: 1.05, backgroundColor: 'rgba(255,255,255,0.15)' }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<div className="text-2xl font-bold">{skinCount}</div>
|
||||||
|
<div className="text-white/80 text-xs">皮肤</div>
|
||||||
|
</motion.div>
|
||||||
|
<motion.div
|
||||||
|
className="bg-white/10 rounded-lg p-3 text-center backdrop-blur-sm"
|
||||||
|
whileHover={{ scale: 1.05, backgroundColor: 'rgba(255,255,255,0.15)' }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<div className="text-2xl font-bold">{favoriteCount}</div>
|
||||||
|
<div className="text-white/80 text-xs">收藏</div>
|
||||||
|
</motion.div>
|
||||||
|
<motion.div
|
||||||
|
className="bg-white/10 rounded-lg p-3 text-center backdrop-blur-sm"
|
||||||
|
whileHover={{ scale: 1.05, backgroundColor: 'rgba(255,255,255,0.15)' }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
<div className="text-2xl font-bold">{points}</div>
|
||||||
|
<div className="text-white/80 text-xs">积分</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user