feat: 完成navbar隐藏优化和侧边栏冻结功能
- 优化navbar滚动隐藏逻辑,更敏感响应 - 添加返回顶部按钮,固定在右下角 - 实现profile页面侧边栏真正冻结效果 - 修复首页滑动指示器位置 - 优化整体布局确保首屏内容完整显示
This commit is contained in:
194
src/components/ErrorNotification.tsx
Normal file
194
src/components/ErrorNotification.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { XMarkIcon, ExclamationTriangleIcon, CheckCircleIcon, InformationCircleIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
export type ErrorType = 'error' | 'warning' | 'success' | 'info';
|
||||
|
||||
interface ErrorNotificationProps {
|
||||
message: string;
|
||||
type?: ErrorType;
|
||||
duration?: number;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function ErrorNotification({ message, type = 'error', duration = 5000, onClose }: ErrorNotificationProps) {
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (duration > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
onClose?.();
|
||||
}, duration);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [duration, onClose]);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsVisible(false);
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
const getIcon = () => {
|
||||
switch (type) {
|
||||
case 'error':
|
||||
return <ExclamationTriangleIcon className="w-5 h-5" />;
|
||||
case 'warning':
|
||||
return <ExclamationTriangleIcon className="w-5 h-5" />;
|
||||
case 'success':
|
||||
return <CheckCircleIcon className="w-5 h-5" />;
|
||||
case 'info':
|
||||
return <InformationCircleIcon className="w-5 h-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
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'
|
||||
};
|
||||
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'
|
||||
};
|
||||
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'
|
||||
};
|
||||
case 'info':
|
||||
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'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const styles = getStyles();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isVisible && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20, scale: 0.9 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -20, scale: 0.9 }}
|
||||
transition={{ duration: 0.3, ease: 'easeOut' }}
|
||||
className={`fixed top-4 right-4 z-50 max-w-sm w-full ${styles.bg} ${styles.border} border rounded-xl shadow-lg backdrop-blur-sm`}
|
||||
>
|
||||
<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">
|
||||
<p className={`text-sm font-medium ${styles.text}`}>
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className={`flex-shrink-0 ml-3 ${styles.close} transition-colors`}
|
||||
>
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
// 全局错误管理器
|
||||
class ErrorManager {
|
||||
private static instance: ErrorManager;
|
||||
private listeners: Array<(notification: ErrorNotificationProps & { id: string }) => void> = [];
|
||||
|
||||
static getInstance(): ErrorManager {
|
||||
if (!ErrorManager.instance) {
|
||||
ErrorManager.instance = new ErrorManager();
|
||||
}
|
||||
return ErrorManager.instance;
|
||||
}
|
||||
|
||||
showError(message: string, duration?: number) {
|
||||
this.showNotification(message, 'error', duration);
|
||||
}
|
||||
|
||||
showWarning(message: string, duration?: number) {
|
||||
this.showNotification(message, 'warning', duration);
|
||||
}
|
||||
|
||||
showSuccess(message: string, duration?: number) {
|
||||
this.showNotification(message, 'success', duration);
|
||||
}
|
||||
|
||||
showInfo(message: string, duration?: number) {
|
||||
this.showNotification(message, 'info', duration);
|
||||
}
|
||||
|
||||
private showNotification(message: string, type: ErrorType, duration?: number) {
|
||||
const notification = {
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
message,
|
||||
type,
|
||||
duration: duration ?? (type === 'error' ? 5000 : 3000)
|
||||
};
|
||||
|
||||
this.listeners.forEach(listener => listener(notification));
|
||||
}
|
||||
|
||||
subscribe(listener: (notification: ErrorNotificationProps & { id: string }) => void) {
|
||||
this.listeners.push(listener);
|
||||
return () => {
|
||||
this.listeners = this.listeners.filter(l => l !== listener);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const errorManager = ErrorManager.getInstance();
|
||||
|
||||
// 错误提示容器组件
|
||||
export function ErrorNotificationContainer() {
|
||||
const [notifications, setNotifications] = useState<Array<ErrorNotificationProps & { id: string }>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = errorManager.subscribe((notification) => {
|
||||
setNotifications(prev => [...prev, notification]);
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
const removeNotification = (id: string) => {
|
||||
setNotifications(prev => prev.filter(n => n.id !== id));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{notifications.map((notification) => (
|
||||
<ErrorNotification
|
||||
key={notification.id}
|
||||
{...notification}
|
||||
onClose={() => removeNotification(notification.id)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
279
src/components/ErrorPage.tsx
Normal file
279
src/components/ErrorPage.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
HomeIcon,
|
||||
ArrowLeftIcon,
|
||||
ExclamationTriangleIcon,
|
||||
XCircleIcon,
|
||||
ClockIcon,
|
||||
ServerIcon,
|
||||
WifiIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
export interface ErrorPageProps {
|
||||
code?: number;
|
||||
title?: string;
|
||||
message?: string;
|
||||
description?: string;
|
||||
type?: '404' | '500' | '403' | 'network' | 'timeout' | 'maintenance' | 'custom';
|
||||
actions?: {
|
||||
primary?: {
|
||||
label: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
secondary?: {
|
||||
label: string;
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
};
|
||||
showContact?: boolean;
|
||||
}
|
||||
|
||||
const errorConfigs = {
|
||||
'404': {
|
||||
icon: <XCircleIcon className="w-16 h-16" />,
|
||||
title: '页面不见了',
|
||||
message: '抱歉,我们找不到您要访问的页面。',
|
||||
description: '它可能已被移动、删除,或者您输入的链接不正确。'
|
||||
},
|
||||
'500': {
|
||||
icon: <ServerIcon className="w-16 h-16" />,
|
||||
title: '服务器错误',
|
||||
message: '抱歉,服务器遇到了一些问题。',
|
||||
description: '我们的团队正在努力解决这个问题,请稍后再试。'
|
||||
},
|
||||
'403': {
|
||||
icon: <ExclamationTriangleIcon className="w-16 h-16" />,
|
||||
title: '访问被拒绝',
|
||||
message: '抱歉,您没有权限访问此页面。',
|
||||
description: '请检查您的账户权限或联系管理员。'
|
||||
},
|
||||
'network': {
|
||||
icon: <WifiIcon className="w-16 h-16" />,
|
||||
title: '网络连接错误',
|
||||
message: '无法连接到服务器。',
|
||||
description: '请检查您的网络连接,然后重试。'
|
||||
},
|
||||
'timeout': {
|
||||
icon: <ClockIcon className="w-16 h-16" />,
|
||||
title: '请求超时',
|
||||
message: '请求处理时间过长。',
|
||||
description: '请刷新页面或稍后再试。'
|
||||
},
|
||||
'maintenance': {
|
||||
icon: <ServerIcon className="w-16 h-16" />,
|
||||
title: '系统维护中',
|
||||
message: '我们正在进行系统维护。',
|
||||
description: '请稍后再试,我们会尽快恢复服务。'
|
||||
}
|
||||
};
|
||||
|
||||
export function ErrorPage({
|
||||
code,
|
||||
title,
|
||||
message,
|
||||
description,
|
||||
type = 'custom',
|
||||
actions,
|
||||
showContact = true
|
||||
}: ErrorPageProps) {
|
||||
const config = errorConfigs[type] || {};
|
||||
const displayTitle = title || config.title || '出错了';
|
||||
const displayMessage = message || config.message || '发生了一些错误';
|
||||
const displayDescription = description || config.description || '';
|
||||
|
||||
const defaultActions = {
|
||||
primary: {
|
||||
label: '返回主页',
|
||||
href: '/'
|
||||
},
|
||||
secondary: {
|
||||
label: '返回上页',
|
||||
onClick: () => window.history.back()
|
||||
}
|
||||
};
|
||||
|
||||
const finalActions = { ...defaultActions, ...actions };
|
||||
|
||||
const getIconColor = () => {
|
||||
switch (type) {
|
||||
case '404': return 'text-orange-500';
|
||||
case '500': return 'text-red-500';
|
||||
case '403': return 'text-yellow-500';
|
||||
case 'network': return 'text-blue-500';
|
||||
case 'timeout': return 'text-purple-500';
|
||||
case 'maintenance': return 'text-gray-500';
|
||||
default: return 'text-orange-500';
|
||||
}
|
||||
};
|
||||
|
||||
const getCodeColor = () => {
|
||||
switch (type) {
|
||||
case '404': return 'from-orange-400 via-orange-500 to-amber-500';
|
||||
case '500': return 'from-red-400 via-red-500 to-pink-500';
|
||||
case '403': return 'from-yellow-400 via-yellow-500 to-orange-500';
|
||||
case 'network': return 'from-blue-400 via-blue-500 to-cyan-500';
|
||||
case 'timeout': return 'from-purple-400 via-purple-500 to-pink-500';
|
||||
case 'maintenance': return 'from-gray-400 via-gray-500 to-slate-500';
|
||||
default: return 'from-orange-400 via-orange-500 to-amber-500';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-orange-50 via-white to-amber-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
|
||||
<motion.div
|
||||
className="text-center px-4 max-w-2xl mx-auto"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, ease: 'easeOut' }}
|
||||
>
|
||||
{/* 错误代码 */}
|
||||
{code && (
|
||||
<motion.div
|
||||
className="mb-8"
|
||||
initial={{ scale: 0.5, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 0.2, duration: 0.6, type: 'spring', stiffness: 200 }}
|
||||
>
|
||||
<h1 className={`text-9xl font-black bg-gradient-to-r ${getCodeColor()} bg-clip-text text-transparent mb-4`}>
|
||||
{code}
|
||||
</h1>
|
||||
<div className={`w-24 h-1 bg-gradient-to-r ${getCodeColor()} mx-auto rounded-full`} />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 图标 */}
|
||||
<motion.div
|
||||
className="mb-8 flex justify-center"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.4, duration: 0.6 }}
|
||||
>
|
||||
<div className={`${getIconColor()}`}>
|
||||
{config.icon || <ExclamationTriangleIcon className="w-16 h-16" />}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 错误信息 */}
|
||||
<motion.div
|
||||
className="mb-8"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.6, duration: 0.6 }}
|
||||
>
|
||||
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{displayTitle}
|
||||
</h2>
|
||||
<p className="text-xl text-gray-700 dark:text-gray-300 mb-2">
|
||||
{displayMessage}
|
||||
</p>
|
||||
{displayDescription && (
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||
{displayDescription}
|
||||
</p>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<motion.div
|
||||
className="flex flex-col sm:flex-row gap-4 justify-center items-center"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.8, duration: 0.6 }}
|
||||
>
|
||||
{finalActions.primary && (
|
||||
finalActions.primary.href ? (
|
||||
<Link
|
||||
href={finalActions.primary.href}
|
||||
className="inline-flex items-center px-6 py-3 bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white font-semibold rounded-xl transition-all duration-200 shadow-lg hover:shadow-xl transform hover:scale-105"
|
||||
>
|
||||
<HomeIcon className="w-5 h-5 mr-2" />
|
||||
{finalActions.primary.label}
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
onClick={finalActions.primary.onClick}
|
||||
className="inline-flex items-center px-6 py-3 bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white font-semibold rounded-xl transition-all duration-200 shadow-lg hover:shadow-xl transform hover:scale-105"
|
||||
>
|
||||
<HomeIcon className="w-5 h-5 mr-2" />
|
||||
{finalActions.primary.label}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
{finalActions.secondary && (
|
||||
finalActions.secondary.href ? (
|
||||
<Link
|
||||
href={finalActions.secondary.href}
|
||||
className="inline-flex items-center px-6 py-3 border-2 border-orange-500 text-orange-500 hover:bg-orange-500 hover:text-white font-semibold rounded-xl transition-all duration-200"
|
||||
>
|
||||
<ArrowLeftIcon className="w-5 h-5 mr-2" />
|
||||
{finalActions.secondary.label}
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
onClick={finalActions.secondary.onClick}
|
||||
className="inline-flex items-center px-6 py-3 border-2 border-orange-500 text-orange-500 hover:bg-orange-500 hover:text-white font-semibold rounded-xl transition-all duration-200"
|
||||
>
|
||||
<ArrowLeftIcon className="w-5 h-5 mr-2" />
|
||||
{finalActions.secondary.label}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* 联系信息 */}
|
||||
{showContact && (
|
||||
<motion.div
|
||||
className="mt-8 text-sm text-gray-500 dark:text-gray-400"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 1, duration: 0.6 }}
|
||||
>
|
||||
<p>如果问题持续存在,请
|
||||
<Link href="/contact" className="text-orange-500 hover:text-orange-600 underline mx-1">
|
||||
联系我们
|
||||
</Link>
|
||||
的支持团队
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* 背景装饰 */}
|
||||
<div className="fixed inset-0 -z-10 overflow-hidden">
|
||||
<div className="absolute top-1/4 left-1/4 w-64 h-64 bg-orange-200/20 dark:bg-orange-900/20 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-amber-200/20 dark:bg-amber-900/20 rounded-full blur-3xl" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 预设的错误页面组件
|
||||
export function NotFoundPage() {
|
||||
return <ErrorPage type="404" code={404} />;
|
||||
}
|
||||
|
||||
export function ServerErrorPage() {
|
||||
return <ErrorPage type="500" code={500} />;
|
||||
}
|
||||
|
||||
export function ForbiddenPage() {
|
||||
return <ErrorPage type="403" code={403} />;
|
||||
}
|
||||
|
||||
export function NetworkErrorPage() {
|
||||
return <ErrorPage type="network" />;
|
||||
}
|
||||
|
||||
export function TimeoutErrorPage() {
|
||||
return <ErrorPage type="timeout" />;
|
||||
}
|
||||
|
||||
export function MaintenancePage() {
|
||||
return <ErrorPage type="maintenance" />;
|
||||
}
|
||||
19
src/components/MainContent.tsx
Normal file
19
src/components/MainContent.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
export function MainContent({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const isAuthPage = pathname === '/auth';
|
||||
const isHomePage = pathname === '/';
|
||||
|
||||
return (
|
||||
<main className={`
|
||||
min-h-screen bg-gradient-to-br from-slate-50 via-orange-50 to-amber-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900
|
||||
${isAuthPage || isHomePage ? '' : 'pt-16'}
|
||||
`}>
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
387
src/components/Navbar.tsx
Normal file
387
src/components/Navbar.tsx
Normal file
@@ -0,0 +1,387 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { Bars3Icon, XMarkIcon, UserCircleIcon } from '@heroicons/react/24/outline';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
export default function Navbar() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isHidden, setIsHidden] = useState(false);
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [showScrollTop, setShowScrollTop] = useState(false);
|
||||
const [navbarHeight, setNavbarHeight] = useState(0);
|
||||
const navbarRef = useRef<HTMLElement>(null);
|
||||
const { user, isAuthenticated, logout } = useAuth();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
// 在auth页面隐藏navbar
|
||||
const isAuthPage = pathname === '/auth';
|
||||
|
||||
// 检测navbar高度并设置CSS自定义属性
|
||||
useEffect(() => {
|
||||
const updateHeight = () => {
|
||||
if (navbarRef.current) {
|
||||
const height = navbarRef.current.offsetHeight;
|
||||
setNavbarHeight(height);
|
||||
document.documentElement.style.setProperty('--navbar-height', `${height}px`);
|
||||
}
|
||||
};
|
||||
|
||||
updateHeight();
|
||||
window.addEventListener('resize', updateHeight);
|
||||
return () => window.removeEventListener('resize', updateHeight);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let lastScrollY = 0;
|
||||
let ticking = false;
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!ticking) {
|
||||
window.requestAnimationFrame(() => {
|
||||
const currentScrollY = window.scrollY;
|
||||
|
||||
// 检测是否滚动到顶部
|
||||
setIsScrolled(currentScrollY > 20);
|
||||
|
||||
// 显示返回顶部按钮(滚动超过300px)
|
||||
setShowScrollTop(currentScrollY > 300);
|
||||
|
||||
// 更敏感的隐藏逻辑:只要往下滚动就隐藏,不管滚动多少
|
||||
if (!isAuthPage && currentScrollY > lastScrollY && currentScrollY > 10) {
|
||||
setIsHidden(true);
|
||||
} else if (currentScrollY < lastScrollY) { // 往上滚动就显示
|
||||
setIsHidden(false);
|
||||
}
|
||||
|
||||
lastScrollY = currentScrollY;
|
||||
ticking = false;
|
||||
});
|
||||
ticking = true;
|
||||
}
|
||||
};
|
||||
|
||||
if (!isAuthPage) {
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
}
|
||||
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, [isAuthPage]);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
router.push('/');
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleLinkClick = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
// 在auth页面不渲染navbar
|
||||
if (isAuthPage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.nav
|
||||
ref={navbarRef}
|
||||
initial={{ y: 0 }}
|
||||
animate={{ y: isHidden ? -100 : 0 }}
|
||||
transition={{ duration: 0.3, ease: 'easeInOut' }}
|
||||
className="fixed top-0 left-0 right-0 z-50 transition-all duration-300 bg-white/80 dark:bg-gray-800/80 backdrop-blur-lg border-b border-gray-200/50 dark:border-gray-700/50"
|
||||
style={{ willChange: 'transform' }}
|
||||
>
|
||||
<div className={`
|
||||
max-w-7xl mx-auto px-4 sm:px-6 lg:px-8
|
||||
${isScrolled ? 'py-3' : 'py-4'}
|
||||
transition-all duration-300
|
||||
`}>
|
||||
<div className="flex justify-between items-center">
|
||||
{/* Logo */}
|
||||
<motion.div
|
||||
className="flex items-center"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<Link href="/" className="flex items-center space-x-3 group">
|
||||
<motion.div
|
||||
className="w-10 h-10 bg-gradient-to-br from-orange-400 via-orange-500 to-orange-600 rounded-xl flex items-center justify-center shadow-lg"
|
||||
whileHover={{ rotate: 5, scale: 1.05 }}
|
||||
transition={{ type: 'spring', stiffness: 300 }}
|
||||
>
|
||||
<span className="text-white font-bold text-lg">C</span>
|
||||
</motion.div>
|
||||
<motion.span
|
||||
className="text-2xl font-black bg-gradient-to-r from-orange-400 to-orange-600 bg-clip-text text-transparent"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
>
|
||||
CarrotSkin
|
||||
</motion.span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden md:flex items-center space-x-6">
|
||||
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
||||
<Link
|
||||
href="/"
|
||||
className="text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 transition-all duration-200 font-medium relative group px-3 py-2 rounded-lg hover:bg-orange-500/10 dark:hover:bg-orange-400/10"
|
||||
>
|
||||
首页
|
||||
<motion.span
|
||||
className="absolute -bottom-0.5 left-3 right-3 h-0.5 bg-gradient-to-r from-orange-400 to-orange-600"
|
||||
initial={{ scaleX: 0 }}
|
||||
whileHover={{ scaleX: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
||||
<Link
|
||||
href="/skins"
|
||||
className="text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 transition-all duration-200 font-medium relative group px-3 py-2 rounded-lg hover:bg-orange-500/10 dark:hover:bg-orange-400/10"
|
||||
>
|
||||
皮肤库
|
||||
<motion.span
|
||||
className="absolute -bottom-0.5 left-3 right-3 h-0.5 bg-gradient-to-r from-orange-400 to-orange-600"
|
||||
initial={{ scaleX: 0 }}
|
||||
whileHover={{ scaleX: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
|
||||
{/* 用户头像框 - 类似知乎和哔哩哔哩的设计 */}
|
||||
{isAuthenticated ? (
|
||||
<div className="flex items-center space-x-4">
|
||||
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
|
||||
<Link
|
||||
href="/profile"
|
||||
className="flex items-center space-x-3 group"
|
||||
onClick={handleLinkClick}
|
||||
>
|
||||
{user?.avatar ? (
|
||||
<motion.div
|
||||
className="relative"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
>
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user.username}
|
||||
className="w-9 h-9 rounded-full border-2 border-orange-500/30 group-hover:border-orange-500 transition-all duration-200 shadow-md"
|
||||
/>
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-full bg-gradient-to-br from-orange-400/20 to-orange-600/20"
|
||||
initial={{ opacity: 0 }}
|
||||
whileHover={{ opacity: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
className="relative"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
>
|
||||
<UserCircleIcon className="w-9 h-9 text-gray-400 group-hover:text-orange-500 transition-all duration-200" />
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-full bg-gradient-to-br from-orange-400/20 to-orange-600/20"
|
||||
initial={{ opacity: 0 }}
|
||||
whileHover={{ opacity: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
<motion.span
|
||||
className="text-gray-700 dark:text-gray-300 group-hover:text-orange-500 dark:group-hover:text-orange-400 transition-all duration-200 font-medium"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
>
|
||||
{user?.username}
|
||||
</motion.span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<motion.button
|
||||
onClick={handleLogout}
|
||||
className="relative overflow-hidden border-2 border-orange-500 text-orange-500 hover:text-white font-medium py-2 px-4 rounded-lg transition-all duration-200 group"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<motion.span
|
||||
className="absolute inset-0 w-0 bg-gradient-to-r from-orange-400 to-orange-600 transition-all duration-300 group-hover:w-full"
|
||||
initial={{ width: 0 }}
|
||||
whileHover={{ width: '100%' }}
|
||||
/>
|
||||
<span className="relative z-10">退出登录</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
) : (
|
||||
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
||||
<Link
|
||||
href="/auth"
|
||||
className="flex items-center space-x-2 text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 transition-all duration-200 group"
|
||||
>
|
||||
<motion.div
|
||||
className="relative"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
>
|
||||
<UserCircleIcon className="w-7 h-7 text-gray-400 group-hover:text-orange-500 transition-all duration-200" />
|
||||
<motion.div
|
||||
className="absolute inset-0 rounded-full bg-gradient-to-br from-orange-400/20 to-orange-600/20"
|
||||
initial={{ opacity: 0 }}
|
||||
whileHover={{ opacity: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
</motion.div>
|
||||
<span className="font-medium">登录</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile menu button */}
|
||||
<div className="md:hidden flex items-center">
|
||||
<motion.button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 transition-colors duration-200 p-2"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
{isOpen ? <XMarkIcon className="w-6 h-6" /> : <Bars3Icon className="w-6 h-6" />}
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Navigation */}
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||
className="md:hidden bg-white/95 dark:bg-gray-900/95 backdrop-blur-md border-t border-gray-200/50 dark:border-gray-700/50"
|
||||
>
|
||||
<div className="px-4 py-4 space-y-1">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.05 }}
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="block px-4 py-3 text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 rounded-lg transition-all duration-200 font-medium"
|
||||
onClick={handleLinkClick}
|
||||
>
|
||||
首页
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<Link
|
||||
href="/skins"
|
||||
className="block px-4 py-3 text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 rounded-lg transition-all duration-200 font-medium"
|
||||
onClick={handleLinkClick}
|
||||
>
|
||||
皮肤库
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
|
||||
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<Link
|
||||
href="/profile"
|
||||
className="block px-4 py-3 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 rounded-lg transition-all duration-200"
|
||||
onClick={handleLinkClick}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
{user?.avatar ? (
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user.username}
|
||||
className="w-8 h-8 rounded-full border-2 border-orange-500/30"
|
||||
/>
|
||||
) : (
|
||||
<UserCircleIcon className="w-8 h-8 text-gray-400" />
|
||||
)}
|
||||
<span className="text-gray-700 dark:text-gray-300 font-medium">{user?.username}</span>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.25 }}
|
||||
>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="block w-full text-left px-4 py-3 text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 rounded-lg transition-all duration-200 font-medium"
|
||||
>
|
||||
退出登录
|
||||
</button>
|
||||
</motion.div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<Link
|
||||
href="/auth"
|
||||
className="block px-4 py-3 text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 rounded-lg transition-all duration-200 font-medium"
|
||||
onClick={handleLinkClick}
|
||||
>
|
||||
登录
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.25 }}
|
||||
>
|
||||
<Link
|
||||
href="/auth"
|
||||
className="block px-4 py-3 bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white rounded-lg transition-all duration-200 shadow-lg hover:shadow-xl font-medium text-center"
|
||||
onClick={handleLinkClick}
|
||||
>
|
||||
注册
|
||||
</Link>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.nav>
|
||||
);
|
||||
}
|
||||
65
src/components/ScrollToTop.tsx
Normal file
65
src/components/ScrollToTop.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
export default function ScrollToTop() {
|
||||
const [showScrollTop, setShowScrollTop] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let ticking = false;
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!ticking) {
|
||||
window.requestAnimationFrame(() => {
|
||||
const currentScrollY = window.scrollY;
|
||||
// 显示返回顶部按钮(滚动超过300px)
|
||||
setShowScrollTop(currentScrollY > 300);
|
||||
ticking = false;
|
||||
});
|
||||
ticking = true;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<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 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
onClick={scrollToTop}
|
||||
className="fixed bottom-6 right-6 w-12 h-12 bg-gradient-to-br from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white rounded-full shadow-lg hover:shadow-xl transition-all duration-200 flex items-center justify-center z-[100] group"
|
||||
whileHover={{ scale: 1.1, y: -2 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5 transition-transform duration-200 group-hover:-translate-y-0.5"
|
||||
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.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
218
src/components/SkinViewer.tsx
Normal file
218
src/components/SkinViewer.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { SkinViewer as SkinViewer3D, WalkingAnimation, FunctionAnimation } from 'skinview3d';
|
||||
|
||||
interface SkinViewerProps {
|
||||
skinUrl: string;
|
||||
capeUrl?: string;
|
||||
isSlim?: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
className?: string;
|
||||
autoRotate?: boolean;
|
||||
walking?: boolean;
|
||||
}
|
||||
|
||||
export default function SkinViewer({
|
||||
skinUrl,
|
||||
capeUrl,
|
||||
isSlim = false,
|
||||
width = 300,
|
||||
height = 300,
|
||||
className = '',
|
||||
autoRotate = true,
|
||||
walking = false,
|
||||
}: SkinViewerProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const viewerRef = useRef<SkinViewer3D | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
|
||||
// 预加载皮肤图片以检查是否可访问
|
||||
useEffect(() => {
|
||||
if (!skinUrl) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setHasError(false);
|
||||
setImageLoaded(false);
|
||||
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous'; // 尝试跨域访问
|
||||
|
||||
img.onload = () => {
|
||||
console.log('皮肤图片加载成功:', skinUrl);
|
||||
setImageLoaded(true);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
img.onerror = (error) => {
|
||||
console.error('皮肤图片加载失败:', skinUrl, error);
|
||||
setHasError(true);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
// 开始加载图片
|
||||
img.src = skinUrl;
|
||||
|
||||
return () => {
|
||||
// 清理
|
||||
img.onload = null;
|
||||
img.onerror = null;
|
||||
};
|
||||
}, [skinUrl]);
|
||||
|
||||
// 初始化3D查看器
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current || !imageLoaded || hasError) return;
|
||||
|
||||
try {
|
||||
console.log('初始化3D皮肤查看器:', { skinUrl, isSlim, width, height });
|
||||
|
||||
// 使用canvas的实际尺寸,参考blessingskin
|
||||
const canvas = canvasRef.current;
|
||||
const viewer = new SkinViewer3D({
|
||||
canvas: canvas,
|
||||
width: canvas.clientWidth || width,
|
||||
height: canvas.clientHeight || height,
|
||||
skin: skinUrl,
|
||||
cape: capeUrl,
|
||||
model: isSlim ? 'slim' : 'default',
|
||||
zoom: 1.0, // 使用blessingskin的zoom方式
|
||||
});
|
||||
|
||||
viewerRef.current = viewer;
|
||||
|
||||
// 设置背景和控制选项 - 参考blessingskin
|
||||
viewer.background = null; // 透明背景
|
||||
viewer.autoRotate = false; // 禁用自动旋转
|
||||
|
||||
// 禁用所有交互控制
|
||||
viewer.controls.enableRotate = false; // 禁用旋转控制
|
||||
viewer.controls.enableZoom = false; // 禁用缩放
|
||||
viewer.controls.enablePan = false; // 禁用平移
|
||||
|
||||
console.log('3D皮肤查看器初始化成功');
|
||||
|
||||
} catch (error) {
|
||||
console.error('3D皮肤查看器初始化失败:', error);
|
||||
setHasError(true);
|
||||
}
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
if (viewerRef.current) {
|
||||
try {
|
||||
viewerRef.current.dispose();
|
||||
viewerRef.current = null;
|
||||
console.log('3D皮肤查看器已清理');
|
||||
} catch (error) {
|
||||
console.error('清理3D皮肤查看器失败:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [skinUrl, capeUrl, isSlim, width, height, autoRotate, walking, imageLoaded, hasError]);
|
||||
|
||||
// 当皮肤URL改变时更新
|
||||
useEffect(() => {
|
||||
if (viewerRef.current && skinUrl && imageLoaded) {
|
||||
try {
|
||||
console.log('更新皮肤URL:', skinUrl);
|
||||
viewerRef.current.loadSkin(skinUrl);
|
||||
} catch (error) {
|
||||
console.error('更新皮肤失败:', error);
|
||||
}
|
||||
}
|
||||
}, [skinUrl, imageLoaded]);
|
||||
|
||||
// 监听容器尺寸变化并调整viewer大小 - 参考blessingskin
|
||||
useEffect(() => {
|
||||
if (viewerRef.current && canvasRef.current) {
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
viewerRef.current.setSize(rect.width, rect.height);
|
||||
}
|
||||
}, [imageLoaded]); // 当图片加载完成后调整尺寸
|
||||
|
||||
// 当披风URL改变时更新
|
||||
useEffect(() => {
|
||||
if (viewerRef.current && capeUrl && imageLoaded) {
|
||||
try {
|
||||
console.log('更新披风URL:', capeUrl);
|
||||
viewerRef.current.loadCape(capeUrl);
|
||||
} catch (error) {
|
||||
console.error('更新披风失败:', error);
|
||||
}
|
||||
} else if (viewerRef.current && !capeUrl && imageLoaded) {
|
||||
try {
|
||||
viewerRef.current.loadCape(null);
|
||||
} catch (error) {
|
||||
console.error('移除披风失败:', error);
|
||||
}
|
||||
}
|
||||
}, [capeUrl, imageLoaded]);
|
||||
|
||||
// 当模型类型改变时更新
|
||||
useEffect(() => {
|
||||
if (viewerRef.current && skinUrl && imageLoaded) {
|
||||
try {
|
||||
console.log('更新模型类型:', isSlim ? 'slim' : 'default');
|
||||
viewerRef.current.loadSkin(skinUrl, { model: isSlim ? 'slim' : 'default' });
|
||||
} catch (error) {
|
||||
console.error('更新模型失败:', error);
|
||||
}
|
||||
}
|
||||
}, [isSlim, skinUrl, imageLoaded]);
|
||||
|
||||
// 错误状态显示
|
||||
if (hasError) {
|
||||
return (
|
||||
<div
|
||||
className={`${className} flex items-center justify-center bg-gradient-to-br from-red-50 to-orange-50 dark:from-red-900/20 dark:to-orange-900/20 rounded-xl border-2 border-dashed border-red-300 dark:border-red-700`}
|
||||
style={{ width: width, height: height }}
|
||||
>
|
||||
<div className="text-center p-4">
|
||||
<div className="text-4xl mb-2">⚠️</div>
|
||||
<div className="text-sm text-red-600 dark:text-red-400 font-medium mb-1">
|
||||
皮肤加载失败
|
||||
</div>
|
||||
<div className="text-xs text-red-500 dark:text-red-500">
|
||||
图片链接可能无效或存在访问限制
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 加载状态显示
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
className={`${className} flex items-center justify-center bg-gradient-to-br from-orange-50 to-amber-50 dark:from-gray-700 dark:to-gray-600 rounded-xl`}
|
||||
style={{ width: width, height: height }}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="animate-spin w-8 h-8 border-3 border-orange-500 border-t-transparent rounded-full mx-auto mb-3"></div>
|
||||
<div className="text-sm text-orange-600 dark:text-orange-400 font-medium">
|
||||
加载中...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={className}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user