Files
carrotskin/src/app/skins/page.tsx
lan a9ff72a9bf feat: 增加用户皮肤管理功能和Yggdrasil密码重置
- 在用户资料页面添加皮肤选择和管理功能,支持上传、配置和移除皮肤
- 实现Yggdrasil密码重置功能,用户可生成新密码并显示
- 优化皮肤展示和选择界面,增强用户体验
- 更新SkinViewer组件,支持跑步和跳跃动画
2025-12-04 22:33:46 +08:00

472 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { MagnifyingGlassIcon, EyeIcon, HeartIcon, ArrowDownTrayIcon, SparklesIcon, FunnelIcon, ArrowsUpDownIcon } from '@heroicons/react/24/outline';
import { HeartIcon as HeartIconSolid } from '@heroicons/react/24/solid';
import SkinViewer from '@/components/SkinViewer';
import SkinDetailModal from '@/components/SkinDetailModal';
import { searchTextures, toggleFavorite, type Texture } from '@/lib/api';
import { useAuth } from '@/contexts/AuthContext';
export default function SkinsPage() {
const [textures, setTextures] = useState<Texture[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [textureType, setTextureType] = useState<'SKIN' | 'CAPE' | 'ALL'>('ALL');
const [sortBy, setSortBy] = useState('最新');
const [isLoading, setIsLoading] = useState(true);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const [favoritedIds, setFavoritedIds] = useState<Set<number>>(new Set());
const [selectedTexture, setSelectedTexture] = useState<Texture | null>(null);
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
const { isAuthenticated } = useAuth();
const sortOptions = ['最新', '最热', '最多下载'];
const pageSize = 20;
// 加载材质数据
const loadTextures = useCallback(async () => {
setIsLoading(true);
try {
console.log('开始加载材质数据,参数:', { searchTerm, textureType, sortBy, page, pageSize });
const response = await searchTextures({
keyword: searchTerm || undefined,
type: textureType !== 'ALL' ? textureType : undefined,
public_only: true,
page,
page_size: pageSize,
});
console.log('API响应数据:', response);
console.log('API响应code:', response.code);
console.log('API响应data:', response.data);
if (response.code === 200 && response.data) {
// 安全地处理数据,避免未定义错误
const textureList = response.data.list || [];
const totalCount = response.data.total || 0;
const totalPagesCount = response.data.total_pages || 1;
console.log('解析后的数据:', { textureList, totalCount, totalPagesCount });
console.log('材质列表长度:', textureList.length);
if (textureList.length > 0) {
console.log('第一个材质数据:', textureList[0]);
console.log('第一个材质URL:', textureList[0].url);
}
let sortedList = [...textureList];
// 客户端排序
if (sortedList.length > 0) {
switch (sortBy) {
case '最热':
sortedList = sortedList.sort((a, b) => (b.favorite_count || 0) - (a.favorite_count || 0));
break;
case '最多下载':
sortedList = sortedList.sort((a, b) => (b.download_count || 0) - (a.download_count || 0));
break;
default: // 最新
sortedList = sortedList.sort((a, b) => {
const dateA = new Date(a.created_at || 0).getTime();
const dateB = new Date(b.created_at || 0).getTime();
return dateB - dateA;
});
}
}
setTextures(sortedList);
setTotal(totalCount);
setTotalPages(totalPagesCount);
console.log('设置状态后的数据:', { sortedListLength: sortedList.length, totalCount, totalPagesCount });
} else {
// API返回错误状态
console.warn('API返回错误:', response.message);
console.warn('API完整响应:', response);
setTextures([]);
setTotal(0);
setTotalPages(1);
}
} catch (error) {
console.error('加载材质失败:', error);
// 发生网络或其他错误时,显示空状态
setTextures([]);
setTotal(0);
setTotalPages(1);
} finally {
setIsLoading(false);
console.log('加载完成isLoading设置为false');
}
}, [searchTerm, textureType, sortBy, page]);
// 处理收藏
const handleFavorite = async (textureId: number) => {
if (!isAuthenticated) {
alert('请先登录');
return;
}
try {
const response = await toggleFavorite(textureId);
if (response.code === 200) {
const newFavoritedIds = new Set(favoritedIds);
if (favoritedIds.has(textureId)) {
newFavoritedIds.delete(textureId);
} else {
newFavoritedIds.add(textureId);
}
setFavoritedIds(newFavoritedIds);
}
} catch (error) {
console.error('收藏操作失败:', error);
}
};
// 处理详细预览
const handleDetailView = (texture: Texture) => {
setSelectedTexture(texture);
setIsDetailModalOpen(true);
};
// 关闭详细预览
const handleCloseDetailModal = () => {
setIsDetailModalOpen(false);
setSelectedTexture(null);
};
// 初始化和搜索
useEffect(() => {
loadTextures();
}, [loadTextures]);
return (
<div className="min-h-screen bg-gradient-to-br from-orange-50/30 via-amber-50/20 to-yellow-50/30 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
className="text-center mb-12"
>
<h1 className="text-4xl md:text-5xl font-bold bg-gradient-to-r from-orange-500 to-amber-500 bg-clip-text text-transparent mb-4">
</h1>
<p className="text-lg text-gray-600 dark:text-gray-300 max-w-2xl mx-auto">
Minecraft 3D
</p>
</motion.div>
{/* Search and Filter Section - 更紧凑的设计 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.6 }}
className="mb-8"
>
<div className="flex flex-col lg:flex-row gap-4 items-end">
{/* 搜索框 - 更紧凑 */}
<div className="flex-1">
<div className="relative">
<MagnifyingGlassIcon className="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="搜索皮肤或披风..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && loadTextures()}
className="w-full pl-12 pr-4 py-3 border border-gray-200 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-orange-500 focus:border-orange-500 bg-white/80 dark:bg-gray-700/80 text-gray-900 dark:text-white transition-all duration-200 hover:border-gray-300 dark:hover:border-gray-500"
/>
</div>
</div>
{/* 类型筛选 - 更紧凑 */}
<div className="lg:w-40">
<div className="relative">
<FunnelIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
<select
value={textureType}
onChange={(e) => {
setTextureType(e.target.value as 'SKIN' | 'CAPE' | 'ALL');
setPage(1);
}}
className="w-full pl-10 pr-8 py-2.5 border border-gray-200 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-orange-500 focus:border-orange-500 bg-white/80 dark:bg-gray-700/80 text-gray-900 dark:text-white transition-all duration-200 hover:border-gray-300 dark:hover:border-gray-500 appearance-none"
>
<option value="ALL"></option>
<option value="SKIN"></option>
<option value="CAPE"></option>
</select>
</div>
</div>
{/* 排序 - 更紧凑 */}
<div className="lg:w-48">
<div className="relative">
<ArrowsUpDownIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
<select
value={sortBy}
onChange={(e) => {
setSortBy(e.target.value);
setPage(1);
}}
className="w-full pl-10 pr-8 py-2.5 border border-gray-200 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-orange-500 focus:border-orange-500 bg-white/80 dark:bg-gray-700/80 text-gray-900 dark:text-white transition-all duration-200 hover:border-gray-300 dark:hover:border-gray-500 appearance-none"
>
{sortOptions.map(option => (
<option key={option} value={option}>{option}</option>
))}
</select>
</div>
</div>
{/* 搜索按钮 - 更简洁 */}
<motion.button
onClick={loadTextures}
className="px-6 py-2.5 bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white font-medium rounded-xl transition-all duration-200 shadow-md hover:shadow-lg"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
</motion.button>
</div>
</motion.div>
{/* 结果统计 - 更简洁 */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
className="mb-6 flex justify-between items-center"
>
<p className="text-gray-600 dark:text-gray-400">
<span className="font-semibold text-orange-500">{total}</span>
</p>
{totalPages > 1 && (
<div className="flex gap-2">
<motion.button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200"
whileHover={{ scale: page === 1 ? 1 : 1.05 }}
whileTap={{ scale: page === 1 ? 1 : 0.95 }}
>
</motion.button>
<span className="px-4 py-2 text-gray-600 dark:text-gray-400">
{page} / {totalPages}
</span>
<motion.button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200"
whileHover={{ scale: page === totalPages ? 1 : 1.05 }}
whileTap={{ scale: page === totalPages ? 1 : 0.95 }}
>
</motion.button>
</div>
)}
</motion.div>
{/* Loading State - 保持但简化 */}
<AnimatePresence>
{isLoading && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
>
{Array.from({ length: 8 }).map((_, i) => (
<motion.div
key={i}
className="animate-pulse"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
>
<div className="bg-gray-200 dark:bg-gray-700 rounded-xl h-64 mb-4"></div>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4 mb-2"></div>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/2"></div>
</motion.div>
))}
</motion.div>
)}
</AnimatePresence>
{/* Results Grid - 更紧凑 */}
<AnimatePresence>
{!isLoading && textures.length > 0 && (
<motion.div
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
>
{textures.map((texture, index) => {
const isFavorited = favoritedIds.has(texture.id);
return (
<motion.div
key={texture.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
className="group relative bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 overflow-hidden border border-white/20 dark:border-gray-700/30"
>
{/* 3D预览区域 - 更紧凑 */}
<div className="relative aspect-square bg-gradient-to-br from-orange-50 to-amber-50 dark:from-gray-700 dark:to-gray-600 overflow-hidden">
<SkinViewer
skinUrl={texture.url}
isSlim={texture.is_slim}
width={300}
height={300}
className="w-full h-full"
autoRotate={true}
walking={false}
/>
{/* 悬停操作按钮 */}
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
<div className="flex gap-3">
<motion.button
onClick={() => handleDetailView(texture)}
className="bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white p-3 rounded-full shadow-lg transition-all duration-200"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
title="详细预览"
>
<EyeIcon className="w-5 h-5" />
</motion.button>
<motion.button
onClick={() => window.open(texture.url, '_blank')}
className="bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white p-3 rounded-full shadow-lg transition-all duration-200"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
title="查看原图"
>
<ArrowDownTrayIcon className="w-5 h-5" />
</motion.button>
</div>
</div>
{/* 标签 */}
<div className="absolute top-3 right-3 flex gap-1.5">
<motion.span
className={`px-2 py-1 text-white text-xs rounded-full font-medium backdrop-blur-sm ${
texture.type === 'SKIN' ? 'bg-blue-500/80' : 'bg-purple-500/80'
}`}
whileHover={{ scale: 1.05 }}
>
{texture.type === 'SKIN' ? '皮肤' : '披风'}
</motion.span>
{texture.is_slim && (
<motion.span
className="px-2 py-1 bg-pink-500/80 text-white text-xs rounded-full font-medium backdrop-blur-sm"
whileHover={{ scale: 1.05 }}
>
</motion.span>
)}
</div>
</div>
{/* Texture Info */}
<div className="p-4">
<h3 className="font-semibold text-gray-900 dark:text-white mb-1 truncate">{texture.name}</h3>
{texture.description && (
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2 leading-relaxed">
{texture.description}
</p>
)}
{/* Stats */}
<div className="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400 mb-4">
<div className="flex items-center space-x-3">
<motion.span
className="flex items-center space-x-1"
whileHover={{ scale: 1.05 }}
>
<HeartIcon className="w-4 h-4 text-red-400" />
<span className="font-medium">{texture.favorite_count}</span>
</motion.span>
<motion.span
className="flex items-center space-x-1"
whileHover={{ scale: 1.05 }}
>
<ArrowDownTrayIcon className="w-4 h-4 text-blue-400" />
<span className="font-medium">{texture.download_count}</span>
</motion.span>
</div>
</div>
{/* Action Buttons */}
<div className="flex gap-2">
<motion.button
onClick={() => handleDetailView(texture)}
className="flex-1 bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white text-sm py-2 px-3 rounded-lg transition-all duration-200 font-medium shadow-md hover:shadow-lg flex items-center justify-center"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<EyeIcon className="w-4 h-4 mr-1" />
</motion.button>
<motion.button
onClick={() => handleFavorite(texture.id)}
className={`px-3 py-2 border rounded-lg transition-all duration-200 font-medium ${
isFavorited
? 'bg-gradient-to-r from-red-500 to-pink-500 border-transparent text-white shadow-md'
: 'border-orange-500 text-orange-500 hover:bg-gradient-to-r hover:from-orange-500 hover:to-orange-600 hover:text-white hover:border-transparent hover:shadow-md'
}`}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
{isFavorited ? (
<HeartIconSolid className="w-4 h-4" />
) : (
<HeartIcon className="w-4 h-4" />
)}
</motion.button>
</div>
</div>
</motion.div>
);
})}
</motion.div>
)}
</AnimatePresence>
{/* Empty State - 简化 */}
<AnimatePresence>
{!isLoading && textures.length === 0 && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="text-center py-16"
>
<div className="text-6xl mb-4">🎨</div>
<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>
</motion.div>
)}
</AnimatePresence>
</div>
{/* 详细预览对话框 */}
<SkinDetailModal
isOpen={isDetailModalOpen}
onClose={handleCloseDetailModal}
texture={selectedTexture}
/>
</div>
);
}