feat: 增加用户皮肤管理功能和Yggdrasil密码重置

- 在用户资料页面添加皮肤选择和管理功能,支持上传、配置和移除皮肤
- 实现Yggdrasil密码重置功能,用户可生成新密码并显示
- 优化皮肤展示和选择界面,增强用户体验
- 更新SkinViewer组件,支持跑步和跳跃动画
This commit is contained in:
lan
2025-12-04 22:33:46 +08:00
parent 5f90f48a1c
commit a9ff72a9bf
6 changed files with 879 additions and 295 deletions

View File

@@ -35,9 +35,14 @@ import {
getUserProfile,
updateUserProfile,
uploadTexture,
getTexture,
generateAvatarUploadUrl,
updateAvatarUrl,
resetYggdrasilPassword,
type Texture,
type Profile
} from '@/lib/api';
import SkinViewer from '@/components/SkinViewer';
interface UserProfile {
id: number;
@@ -78,6 +83,11 @@ export default function ProfilePage() {
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
const [avatarUploadProgress, setAvatarUploadProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const [profileSkins, setProfileSkins] = useState<Record<string, { url: string; isSlim: boolean }>>({});
const [showSkinSelector, setShowSkinSelector] = useState<string | null>(null);
const [yggdrasilPassword, setYggdrasilPassword] = useState<string>('');
const [showYggdrasilPassword, setShowYggdrasilPassword] = useState<boolean>(false);
const [isResettingYggdrasilPassword, setIsResettingYggdrasilPassword] = useState<boolean>(false);
const { user, isAuthenticated, logout } = useAuth();
@@ -104,6 +114,34 @@ export default function ProfilePage() {
const profilesResponse = await getProfiles();
if (profilesResponse.code === 200) {
setProfiles(profilesResponse.data);
// 加载每个角色的皮肤信息
const skinPromises = profilesResponse.data
.filter(profile => profile.skin_id)
.map(async (profile) => {
try {
const skinResponse = await getTexture(profile.skin_id!);
if (skinResponse.code === 200 && skinResponse.data) {
return {
uuid: profile.uuid,
url: skinResponse.data.url,
isSlim: skinResponse.data.is_slim
};
}
} catch (error) {
console.error(`加载角色 ${profile.uuid} 的皮肤失败:`, error);
}
return null;
});
const skinResults = await Promise.all(skinPromises);
const skinMap: Record<string, { url: string; isSlim: boolean }> = {};
skinResults.forEach((result) => {
if (result) {
skinMap[result.uuid] = { url: result.url, isSlim: result.isSlim };
}
});
setProfileSkins(skinMap);
} else {
throw new Error(profilesResponse.message || '获取角色列表失败');
}
@@ -163,6 +201,11 @@ export default function ProfilePage() {
const response = await deleteProfile(uuid);
if (response.code === 200) {
setProfiles(prev => prev.filter(profile => profile.uuid !== uuid));
setProfileSkins(prev => {
const newSkins = { ...prev };
delete newSkins[uuid];
return newSkins;
});
alert('角色删除成功!');
} else {
throw new Error(response.message || '删除角色失败');
@@ -392,9 +435,9 @@ export default function ProfilePage() {
if (!confirm('确定要删除头像吗?')) return;
try {
const response = await updateAvatarUrl('');
const response = await updateUserProfile({ avatar: '' });
if (response.code === 200) {
setUserProfile(prev => prev ? { ...prev, avatar: undefined } : null);
setUserProfile(response.data);
alert('头像删除成功!');
} else {
throw new Error(response.message || '删除头像失败');
@@ -405,6 +448,85 @@ export default function ProfilePage() {
}
};
const handleResetYggdrasilPassword = async () => {
if (!confirm('确定要重置Yggdrasil密码吗这将生成一个新的密码。')) return;
setIsResettingYggdrasilPassword(true);
try {
const response = await resetYggdrasilPassword();
if (response.code === 200) {
setYggdrasilPassword(response.data.password);
setShowYggdrasilPassword(true);
alert('Yggdrasil密码重置成功请妥善保管新密码。');
} else {
throw new Error(response.message || '重置Yggdrasil密码失败');
}
} catch (error) {
console.error('重置Yggdrasil密码失败:', error);
alert(error instanceof Error ? error.message : '重置Yggdrasil密码失败请稍后重试');
} finally {
setIsResettingYggdrasilPassword(false);
}
};
const handleAssignSkin = async (profileUuid: string, skinId: number) => {
try {
const response = await updateProfile(profileUuid, { skin_id: skinId });
if (response.code === 200) {
// 更新角色数据
setProfiles(prev => prev.map(profile =>
profile.uuid === profileUuid ? response.data : profile
));
// 更新皮肤显示
const skinResponse = await getTexture(skinId);
if (skinResponse.code === 200 && skinResponse.data) {
setProfileSkins(prev => ({
...prev,
[profileUuid]: {
url: skinResponse.data.url,
isSlim: skinResponse.data.is_slim
}
}));
}
setShowSkinSelector(null);
alert('皮肤配置成功!');
} else {
throw new Error(response.message || '配置皮肤失败');
}
} catch (error) {
console.error('配置皮肤失败:', error);
alert(error instanceof Error ? error.message : '配置皮肤失败,请稍后重试');
}
};
const handleRemoveSkin = async (profileUuid: string) => {
try {
const response = await updateProfile(profileUuid, { skin_id: undefined });
if (response.code === 200) {
setProfiles(prev => prev.map(profile =>
profile.uuid === profileUuid ? response.data : profile
));
// 移除皮肤显示
setProfileSkins(prev => {
const newSkins = { ...prev };
delete newSkins[profileUuid];
return newSkins;
});
alert('皮肤移除成功!');
} else {
throw new Error(response.message || '移除皮肤失败');
}
} catch (error) {
console.error('移除皮肤失败:', error);
alert(error instanceof Error ? error.message : '移除皮肤失败,请稍后重试');
}
};
const sidebarVariants = {
hidden: { x: -100, opacity: 0 },
visible: { x: 0, opacity: 1, transition: { duration: 0.5, ease: "easeOut" as const } }
@@ -680,6 +802,118 @@ export default function ProfilePage() {
)}
</AnimatePresence>
{/* Skin Selector Modal */}
<AnimatePresence>
{showSkinSelector && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
>
<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-4xl mx-4 max-h-[80vh] overflow-y-auto"
>
<div className="flex justify-between items-center mb-6">
<h3 className="text-2xl font-bold text-gray-900 dark:text-white"></h3>
<button
onClick={() => setShowSkinSelector(null)}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<XMarkIcon className="w-6 h-6" />
</button>
</div>
{mySkins.length === 0 ? (
<div className="text-center py-12">
<PhotoIcon className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h4 className="text-lg font-semibold text-gray-900 dark:text-white mb-2"></h4>
<p className="text-gray-600 dark:text-gray-400 mb-4"></p>
<button
onClick={() => {
setShowSkinSelector(null);
setShowUploadSkin(true);
}}
className="bg-gradient-to-r from-orange-500 to-amber-500 text-white px-4 py-2 rounded-xl hover:from-orange-600 hover:to-amber-600 transition-all"
>
</button>
</div>
) : (
<>
<div className="mb-4">
<button
onClick={() => handleRemoveSkin(showSkinSelector)}
className="border border-red-500 text-red-500 hover:bg-red-500 hover:text-white px-4 py-2 rounded-lg transition-all duration-200"
>
</button>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
{mySkins.map((skin) => (
<motion.div
key={skin.id}
className="bg-white/50 dark:bg-gray-700/50 rounded-xl overflow-hidden border border-gray-200 dark:border-gray-600 cursor-pointer hover:shadow-lg transition-all duration-200"
whileHover={{ scale: 1.02, y: -2 }}
onClick={() => handleAssignSkin(showSkinSelector, skin.id)}
>
<div className="aspect-square bg-gradient-to-br from-orange-100 to-amber-100 dark:from-gray-600 dark:to-gray-500 relative overflow-hidden">
{skin.type === 'SKIN' ? (
<SkinViewer
skinUrl={skin.url}
isSlim={skin.is_slim}
width={200}
height={200}
className="w-full h-full"
autoRotate={false}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<div className="w-12 h-12 mx-auto mb-1 bg-white dark:bg-gray-800 rounded-lg shadow-lg flex items-center justify-center">
<span className="text-lg">🧥</span>
</div>
<p className="text-xs text-gray-600 dark:text-gray-300 font-medium"></p>
</div>
</div>
)}
{!skin.is_public && (
<div className="absolute top-2 right-2 px-1 py-0.5 bg-gray-800/80 text-white text-xs rounded backdrop-blur-sm">
</div>
)}
</div>
<div className="p-3">
<h4 className="font-semibold text-gray-900 dark:text-white text-sm mb-1 truncate">{skin.name}</h4>
<p className="text-xs text-gray-600 dark:text-gray-400 mb-2">
{skin.type === 'SKIN' ? (skin.is_slim ? '细臂模型' : '经典模型') : '披风'}
</p>
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
<span className="flex items-center space-x-1">
<ArrowDownTrayIcon className="w-3 h-3" />
<span>{skin.download_count}</span>
</span>
<span className="flex items-center space-x-1">
<EyeIcon className="w-3 h-3" />
<span>{skin.favorite_count}</span>
</span>
</div>
</div>
</motion.div>
))}
</div>
</>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
{profiles.length === 0 ? (
<div className="bg-white/50 dark:bg-gray-800/50 backdrop-blur-lg rounded-2xl p-12 text-center">
<UserIcon className="w-16 h-16 text-gray-400 mx-auto mb-4" />
@@ -716,8 +950,21 @@ export default function ProfilePage() {
)}
</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">
<div className="w-20 h-20 bg-gradient-to-br from-orange-400 to-amber-500 rounded-lg shadow-lg"></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 overflow-hidden relative">
{profileSkins[profile.uuid] ? (
<SkinViewer
skinUrl={profileSkins[profile.uuid].url}
isSlim={profileSkins[profile.uuid].isSlim}
width={400}
height={400}
className="w-full h-full"
autoRotate={false}
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<div className="w-20 h-20 bg-gradient-to-br from-orange-400 to-amber-500 rounded-lg shadow-lg"></div>
</div>
)}
</div>
<div className="flex gap-2">
@@ -729,6 +976,12 @@ export default function ProfilePage() {
使
</button>
)}
<button
onClick={() => setShowSkinSelector(profile.uuid)}
className="flex-1 bg-gradient-to-r from-blue-500 to-indigo-500 hover:from-blue-600 hover:to-indigo-600 text-white text-sm py-2 px-3 rounded-lg transition-all duration-200"
>
{profile.skin_id ? '更换皮肤' : '配置皮肤'}
</button>
<button
onClick={() => {
setEditingProfile(profile.uuid);
@@ -926,10 +1179,30 @@ export default function ProfilePage() {
whileHover={{ scale: 1.02, y: -5 }}
transition={{ duration: 0.2 }}
>
<div className="aspect-square bg-gradient-to-br from-orange-100 to-amber-100 dark:from-gray-700 dark:to-gray-600 relative">
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-24 h-24 bg-gradient-to-br from-orange-400 to-amber-500 rounded-lg shadow-lg"></div>
</div>
<div className="aspect-square bg-gradient-to-br from-orange-100 to-amber-100 dark:from-gray-700 dark:to-gray-600 relative overflow-hidden group">
{skin.type === 'SKIN' ? (
<SkinViewer
skinUrl={skin.url}
isSlim={skin.is_slim}
width={400}
height={400}
className="w-full h-full transition-transform duration-300 group-hover:scale-105"
autoRotate={false}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<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: 5 }}
transition={{ type: 'spring', stiffness: 300 }}
>
<span className="text-xl">🧥</span>
</motion.div>
<p className="text-sm text-gray-600 dark:text-gray-300 font-medium"></p>
</div>
</div>
)}
{!skin.is_public && (
<div className="absolute top-3 right-3 px-2 py-1 bg-gray-800/80 text-white text-xs rounded-full backdrop-blur-sm">
@@ -1008,10 +1281,30 @@ export default function ProfilePage() {
whileHover={{ scale: 1.02, y: -5 }}
transition={{ duration: 0.2 }}
>
<div className="aspect-square bg-gradient-to-br from-orange-100 to-amber-100 dark:from-gray-700 dark:to-gray-600 relative">
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-24 h-24 bg-gradient-to-br from-orange-400 to-amber-500 rounded-lg shadow-lg"></div>
</div>
<div className="aspect-square bg-gradient-to-br from-orange-100 to-amber-100 dark:from-gray-700 dark:to-gray-600 relative overflow-hidden group">
{skin.type === 'SKIN' ? (
<SkinViewer
skinUrl={skin.url}
isSlim={skin.is_slim}
width={400}
height={400}
className="w-full h-full transition-transform duration-300 group-hover:scale-105"
autoRotate={false}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<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: 5 }}
transition={{ type: 'spring', stiffness: 300 }}
>
<span className="text-xl">🧥</span>
</motion.div>
<p className="text-sm text-gray-600 dark:text-gray-300 font-medium"></p>
</div>
</div>
)}
<div className="absolute top-3 right-3 px-2 py-1 bg-red-500/80 text-white text-xs rounded-full backdrop-blur-sm flex items-center space-x-1">
<HeartIcon className="w-3 h-3" />
<span></span>
@@ -1279,7 +1572,7 @@ export default function ProfilePage() {
</div>
</motion.div>
{/* API Settings */}
{/* Yggdrasil Settings */}
<motion.div
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={{ y: -2 }}
@@ -1287,31 +1580,49 @@ export default function ProfilePage() {
>
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center space-x-2">
<KeyIcon className="w-5 h-5" />
<span>API设置</span>
<span>Yggdrasil设置</span>
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Yggdrasil API密钥
Yggdrasil密码
</label>
<div className="flex gap-2">
<input
type="password"
value="your-api-key-here"
type={showYggdrasilPassword ? "text" : "password"}
value={yggdrasilPassword || "点击重置按钮生成新密码"}
readOnly
className="flex-1 px-4 py-3 bg-white/50 dark:bg-gray-700/50 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-orange-500 focus:border-transparent transition-all duration-200"
/>
<motion.button
className="bg-gradient-to-r from-orange-500 to-amber-500 text-white px-4 py-2 rounded-xl shadow-lg hover:shadow-xl transition-all duration-200"
onClick={() => setShowYggdrasilPassword(!showYggdrasilPassword)}
className="bg-gradient-to-r from-gray-500 to-gray-600 text-white px-4 py-2 rounded-xl shadow-lg hover:shadow-xl transition-all duration-200"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
disabled={!yggdrasilPassword}
>
{showYggdrasilPassword ? "隐藏" : "显示"}
</motion.button>
<motion.button
onClick={handleResetYggdrasilPassword}
disabled={isResettingYggdrasilPassword}
className="bg-gradient-to-r from-orange-500 to-amber-500 text-white px-4 py-2 rounded-xl shadow-lg hover:shadow-xl transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
{isResettingYggdrasilPassword ? "重置中..." : "重置密码"}
</motion.button>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Minecraft客户端连接
Minecraft客户端连接Yggdrasil认证系统
</p>
{yggdrasilPassword && (
<div className="mt-3 p-3 bg-amber-100 dark:bg-amber-900/30 border border-amber-300 dark:border-amber-700 rounded-lg">
<p className="text-sm text-amber-800 dark:text-amber-200">
<strong> </strong>
</p>
</div>
)}
</div>
</div>
</motion.div>

View File

@@ -5,6 +5,7 @@ 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';
@@ -18,6 +19,8 @@ export default function SkinsPage() {
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 = ['最新', '最热', '最多下载'];
@@ -100,103 +103,88 @@ export default function SkinsPage() {
}
}, [searchTerm, textureType, sortBy, page]);
useEffect(() => {
loadTextures();
}, [loadTextures]);
// 处理收藏
const handleFavorite = async (id: number) => {
const handleFavorite = async (textureId: number) => {
if (!isAuthenticated) {
alert('请先登录');
return;
}
try {
const response = await toggleFavorite(id);
const response = await toggleFavorite(textureId);
if (response.code === 200) {
setFavoritedIds(prev => {
const newSet = new Set(prev);
if (response.data.is_favorited) {
newSet.add(id);
} else {
newSet.delete(id);
}
return newSet;
});
// 更新本地数据
setTextures(prev => prev.map(texture =>
texture.id === id
? {
...texture,
favorite_count: response.data.is_favorited
? texture.favorite_count + 1
: Math.max(0, texture.favorite_count - 1)
}
: texture
));
const newFavoritedIds = new Set(favoritedIds);
if (favoritedIds.has(textureId)) {
newFavoritedIds.delete(textureId);
} else {
newFavoritedIds.add(textureId);
}
setFavoritedIds(newFavoritedIds);
}
} catch (error) {
console.error('收藏操作失败:', error);
alert('操作失败,请稍后重试');
}
};
return (
<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">
{/* Animated Background - 保持背景但简化 */}
<div className="fixed inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-40 -right-40 w-80 h-80 bg-gradient-to-br from-orange-400/10 to-amber-400/10 rounded-full blur-3xl animate-pulse"></div>
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-gradient-to-tr from-pink-400/10 to-orange-400/10 rounded-full blur-3xl animate-pulse delay-1000"></div>
</div>
// 处理详细预览
const handleDetailView = (texture: Texture) => {
setSelectedTexture(texture);
setIsDetailModalOpen(true);
};
<div className="relative z-0 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* 简化的头部区域 */}
// 关闭详细预览
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, ease: "easeOut" }}
className="mb-8"
transition={{ duration: 0.6 }}
className="text-center mb-12"
>
<h1 className="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-2">
<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-gray-600 dark:text-gray-400">
Minecraft皮肤与披风
<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.1, duration: 0.5 }}
className="bg-white/60 dark:bg-gray-800/60 backdrop-blur-md rounded-2xl shadow-lg p-6 mb-6 border border-white/10 dark:border-gray-700/30"
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-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
<MagnifyingGlassIcon className="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="搜索皮肤披风或作者..."
placeholder="搜索皮肤披风..."
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setPage(1);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
loadTextures();
}
}}
className="w-full pl-10 pr-4 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 placeholder-gray-500 dark:placeholder-gray-400 transition-all duration-200 hover:border-gray-300 dark:hover:border-gray-500"
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-48">
<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
@@ -299,137 +287,149 @@ export default function SkinsPage() {
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
>
<div className="bg-gray-200 dark:bg-gray-700 rounded-xl aspect-square mb-3"></div>
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded mb-2"></div>
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-2/3"></div>
<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>
{/* Textures Grid - 保持卡片设计但简化 */}
{/* Results Grid - 更紧凑 */}
<AnimatePresence>
{!isLoading && (
<motion.div
{!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 }}
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
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.05 }}
className="group relative"
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"
>
<div className="bg-white/60 dark:bg-gray-800/60 backdrop-blur-md rounded-2xl shadow-lg hover:shadow-xl transition-all duration-300 border border-white/10 dark:border-gray-700/30 overflow-hidden">
{/* 3D Skin Preview */}
<div className="aspect-square bg-gradient-to-br from-orange-50 to-amber-50 dark:from-gray-700 dark:to-gray-600 relative overflow-hidden group flex items-center justify-center">
{texture.type === 'SKIN' ? (
<SkinViewer
skinUrl={texture.url}
isSlim={texture.is_slim}
width={400}
height={400}
className="w-full h-full transition-transform duration-300 group-hover:scale-105"
autoRotate={false}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<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: 5 }}
transition={{ type: 'spring', stiffness: 300 }}
>
<span className="text-xl">🧥</span>
</motion.div>
<p className="text-sm text-gray-600 dark:text-gray-300 font-medium"></p>
</div>
</div>
)}
{/* 标签 */}
<div className="absolute top-3 right-3 flex gap-1.5">
{/* 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 text-white text-xs rounded-full font-medium backdrop-blur-sm ${
texture.type === 'SKIN' ? 'bg-blue-500/80' : 'bg-purple-500/80'
}`}
className="px-2 py-1 bg-pink-500/80 text-white text-xs rounded-full font-medium backdrop-blur-sm"
whileHover={{ scale: 1.05 }}
>
{texture.type === 'SKIN' ? '皮肤' : '披风'}
</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>
{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={() => window.open(texture.url, '_blank')}
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"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
</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>
{/* 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>
@@ -448,15 +448,24 @@ export default function SkinsPage() {
exit={{ opacity: 0, scale: 0.9 }}
className="text-center py-16"
>
<div className="w-20 h-20 bg-gray-200 dark:bg-gray-700 rounded-full flex items-center justify-center mx-auto mb-4">
<MagnifyingGlassIcon className="w-10 h-10 text-gray-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2"></h3>
<p className="text-gray-600 dark:text-gray-400"></p>
<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>
);
}