Files
carrotskin/src/app/profile/page.tsx

1135 lines
46 KiB
TypeScript
Raw Normal View History

'use client';
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { useAuth } from '@/contexts/AuthContext';
import {
UserCircleIcon,
Cog6ToothIcon,
XCircleIcon,
ArrowPathIcon,
PlusIcon,
XMarkIcon,
UserIcon,
PhotoIcon,
HeartIcon,
TrashIcon,
PencilIcon,
KeyIcon,
EnvelopeIcon,
CloudArrowUpIcon,
EyeIcon,
ArrowDownTrayIcon,
ArrowLeftOnRectangleIcon
} from '@heroicons/react/24/outline';
import {
getMyTextures,
getFavoriteTextures,
toggleFavorite,
getProfiles,
createProfile,
updateProfile,
deleteProfile,
setActiveProfile,
getUserProfile,
updateUserProfile,
uploadTexture,
getTexture,
generateAvatarUploadUrl,
updateAvatarUrl,
resetYggdrasilPassword,
type Texture,
type Profile
} from '@/lib/api';
import { messageManager } from '@/components/MessageNotification';
// 导入新的组件
import UserProfileCard from '@/components/profile/UserProfileCard';
import ProfileSidebar from '@/components/profile/ProfileSidebar';
import MySkinsTab from '@/components/profile/MySkinsTab';
import FavoritesTab from '@/components/profile/FavoritesTab';
import UploadSkinModal from '@/components/profile/UploadSkinModal';
import SkinViewer from '@/components/SkinViewer'; // Added SkinViewer import
import CharacterCard from '@/components/profile/CharacterCard'; // Added CharacterCard import
interface UserProfile {
id: number;
username: string;
email: string;
avatar?: string;
points: number;
role: string;
status: number;
last_login_at?: string;
created_at: string;
updated_at: string;
}
export default function ProfilePage() {
const [activeTab, setActiveTab] = useState<'characters' | 'skins' | 'favorites' | 'settings'>('characters');
const [profiles, setProfiles] = useState<Profile[]>([]);
const [mySkins, setMySkins] = useState<Texture[]>([]);
const [favoriteSkins, setFavoriteSkins] = useState<Texture[]>([]);
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isUploading, setIsUploading] = useState(false);
const [showCreateCharacter, setShowCreateCharacter] = useState(false);
const [showUploadSkin, setShowUploadSkin] = useState(false);
const [newCharacterName, setNewCharacterName] = useState('');
const [newSkinData, setNewSkinData] = useState({
name: '',
description: '',
type: 'SKIN' as 'SKIN' | 'CAPE',
is_public: false,
is_slim: false
});
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [editingProfile, setEditingProfile] = useState<string | null>(null);
const [editProfileName, setEditProfileName] = useState('');
const [uploadProgress, setUploadProgress] = useState(0);
const [avatarFile, setAvatarFile] = useState<File | null>(null);
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();
// 加载用户数据
useEffect(() => {
if (isAuthenticated) {
loadUserData();
}
}, [isAuthenticated]);
const loadUserData = async () => {
setIsLoading(true);
setError(null);
try {
// 加载用户信息
const userResponse = await getUserProfile();
if (userResponse.code === 200) {
setUserProfile(userResponse.data);
} else {
throw new Error(userResponse.message || '获取用户信息失败');
}
// 加载用户档案
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 || '获取角色列表失败');
}
// 加载用户皮肤
const mySkinsResponse = await getMyTextures({ page: 1, page_size: 50 });
if (mySkinsResponse.code === 200) {
setMySkins(mySkinsResponse.data.list || []);
} else {
throw new Error(mySkinsResponse.message || '获取皮肤列表失败');
}
// 加载收藏的皮肤
const favoritesResponse = await getFavoriteTextures({ page: 1, page_size: 50 });
if (favoritesResponse.code === 200) {
setFavoriteSkins(favoritesResponse.data.list || []);
} else {
throw new Error(favoritesResponse.message || '获取收藏列表失败');
}
} catch (error) {
console.error('加载用户数据失败:', error);
setError(error instanceof Error ? error.message : '加载数据失败,请稍后重试');
} finally {
setIsLoading(false);
}
};
const handleToggleSkinVisibility = async (skinId: number) => {
try {
const skin = mySkins.find(s => s.id === skinId);
if (!skin) return;
// TODO: 添加更新皮肤API调用
setMySkins(prev => prev.map(skin =>
skin.id === skinId ? { ...skin, is_public: !skin.is_public } : skin
));
} catch (error) {
console.error('切换皮肤可见性失败:', error);
}
};
const handleDeleteSkin = async (skinId: number) => {
if (!confirm('确定要删除这个皮肤吗?')) return;
setMySkins(prev => prev.filter(skin => skin.id !== skinId));
};
const handleToggleFavorite = async (skinId: number) => {
try {
const response = await toggleFavorite(skinId);
if (response.code === 200) {
// 重新加载收藏数据
const favoritesResponse = await getFavoriteTextures({ page: 1, page_size: 50 });
if (favoritesResponse.code === 200) {
setFavoriteSkins(favoritesResponse.data.list || []);
}
}
} catch (error) {
console.error('切换收藏状态失败:', error);
}
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setSelectedFile(file);
}
};
const handleUploadSkin = async (file: File, data: { name: string; description: string; type: 'SKIN' | 'CAPE'; is_public: boolean; is_slim: boolean }) => {
if (!file || !data.name.trim()) {
messageManager.warning('请选择皮肤文件并输入皮肤名称', { duration: 3000 });
return;
}
setIsUploading(true);
setUploadProgress(0);
try {
// 使用直接上传接口
const progressInterval = setInterval(() => {
setUploadProgress(prev => Math.min(prev + 10, 80));
}, 200);
const response = await uploadTexture(file, {
name: data.name.trim(),
description: data.description.trim(),
type: data.type,
is_public: data.is_public,
is_slim: data.is_slim
});
clearInterval(progressInterval);
setUploadProgress(100);
if (response.code === 200) {
// 重新加载皮肤数据
const mySkinsResponse = await getMyTextures({ page: 1, page_size: 50 });
if (mySkinsResponse.code === 200) {
setMySkins(mySkinsResponse.data.list || []);
}
// 重置表单
setSelectedFile(null);
setNewSkinData({
name: '',
description: '',
type: 'SKIN',
is_public: false,
is_slim: false
});
setShowUploadSkin(false);
messageManager.success('皮肤上传成功!', { duration: 3000 });
} else {
throw new Error(response.message || '上传皮肤失败');
}
} catch (error) {
console.error('上传皮肤失败:', error);
messageManager.error(error instanceof Error ? error.message : '上传皮肤失败,请稍后重试', { duration: 3000 });
} finally {
setIsUploading(false);
setUploadProgress(0);
}
};
const handleAvatarFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// 检查文件类型
if (!file.type.startsWith('image/')) {
messageManager.warning('请选择图片文件', { duration: 3000 });
return;
}
// 检查文件大小 (2MB)
if (file.size > 2 * 1024 * 1024) {
messageManager.warning('文件大小不能超过2MB', { duration: 3000 });
return;
}
setAvatarFile(file);
};
const handleUploadAvatar = async () => {
if (!avatarFile) return;
setIsUploadingAvatar(true);
setAvatarUploadProgress(0);
try {
// 获取上传URL
const uploadUrlResponse = await generateAvatarUploadUrl(avatarFile.name);
if (uploadUrlResponse.code !== 200) {
throw new Error(uploadUrlResponse.message || '获取上传URL失败');
}
const { post_url, form_data, avatar_url } = uploadUrlResponse.data;
// 模拟上传进度
const progressInterval = setInterval(() => {
setAvatarUploadProgress(prev => Math.min(prev + 20, 80));
}, 200);
// 上传文件到预签名URL
const formData = new FormData();
Object.entries(form_data).forEach(([key, value]) => {
formData.append(key, value as string);
});
formData.append('file', avatarFile);
const uploadResponse = await fetch(post_url, {
method: 'POST',
body: formData,
});
if (!uploadResponse.ok) {
throw new Error('文件上传失败');
}
clearInterval(progressInterval);
setAvatarUploadProgress(100);
// 更新用户头像URL
const response = await updateAvatarUrl(avatar_url);
if (response.code === 200) {
setUserProfile(prev => prev ? { ...prev, avatar: avatar_url } : null);
messageManager.success('头像上传成功!', { duration: 3000 });
} else {
throw new Error(response.message || '更新头像URL失败');
}
} catch (error) {
console.error('头像上传失败:', error);
messageManager.error(error instanceof Error ? error.message : '头像上传失败,请稍后重试', { duration: 3000 });
} finally {
setIsUploadingAvatar(false);
setAvatarUploadProgress(0);
setAvatarFile(null);
}
};
const handleDeleteAvatar = async () => {
if (!confirm('确定要删除头像吗?')) return;
try {
const response = await updateUserProfile({ avatar: '' });
if (response.code === 200) {
setUserProfile(response.data);
messageManager.success('头像删除成功!', { duration: 3000 });
} else {
throw new Error(response.message || '删除头像失败');
}
} catch (error) {
console.error('删除头像失败:', error);
messageManager.error(error instanceof Error ? error.message : '删除头像失败,请稍后重试', { duration: 3000 });
} finally {
setAvatarFile(null);
}
};
const handleResetYggdrasilPassword = async () => {
if (!confirm('确定要重置Yggdrasil密码吗这将生成一个新的密码。')) return;
setIsResettingYggdrasilPassword(true);
try {
const response = await resetYggdrasilPassword();
if (response.code === 200) {
messageManager.success('Yggdrasil密码重置成功请妥善保管新密码。', { duration: 5000 });
} else {
throw new Error(response.message || '重置Yggdrasil密码失败');
}
} catch (error) {
console.error('重置Yggdrasil密码失败:', error);
messageManager.error(error instanceof Error ? error.message : '重置Yggdrasil密码失败请稍后重试', { duration: 3000 });
} finally {
setIsResettingYggdrasilPassword(false);
}
};
const handleCreateCharacter = async () => {
if (!newCharacterName.trim()) {
messageManager.warning('请输入角色名称', { duration: 3000 });
return;
}
try {
const response = await createProfile(newCharacterName.trim());
if (response.code === 200) {
setProfiles(prev => [...prev, response.data]);
setNewCharacterName('');
messageManager.success('角色创建成功!', { duration: 3000 });
} else {
throw new Error(response.message || '创建角色失败');
}
} catch (error) {
console.error('创建角色失败:', error);
messageManager.error(error instanceof Error ? error.message : '创建角色失败,请稍后重试', { duration: 3000 });
} finally {
}
};
const handleDeleteCharacter = async (uuid: string) => {
const character = profiles.find(p => p.uuid === uuid);
if (!character) return;
if (!confirm(`确定要删除角色 "${character.name}" 吗?此操作不可恢复。`)) return;
try {
const response = await deleteProfile(uuid);
if (response.code === 200) {
setProfiles(prev => prev.filter(profile => profile.uuid !== uuid));
messageManager.success('角色删除成功!', { duration: 3000 });
} else {
throw new Error(response.message || '删除角色失败');
}
} catch (error) {
console.error('删除角色失败:', error);
messageManager.error(error instanceof Error ? error.message : '删除角色失败,请稍后重试', { duration: 3000 });
} finally {
}
};
const handleSetActiveCharacter = async (uuid: string) => {
try {
const response = await setActiveProfile(uuid);
if (response.code === 200) {
setProfiles(prev => prev.map(profile => ({
...profile,
is_active: profile.uuid === uuid
})));
messageManager.success('角色切换成功!', { duration: 3000 });
} else {
throw new Error(response.message || '设置活跃角色失败');
}
} catch (error) {
console.error('设置活跃角色失败:', error);
messageManager.error(error instanceof Error ? error.message : '设置活跃角色失败,请稍后重试', { duration: 3000 });
} finally {
}
};
const handleEditCharacter = async () => {
if (!editProfileName.trim()) {
messageManager.warning('请输入角色名称', { duration: 3000 });
return;
}
try {
const response = await updateProfile(editingProfile!, { name: editProfileName.trim() });
if (response.code === 200) {
setProfiles(prev => prev.map(profile =>
profile.uuid === editingProfile ? response.data : profile
));
setEditingProfile(null);
setEditProfileName('');
messageManager.success('角色编辑成功!', { duration: 3000 });
} else {
throw new Error(response.message || '编辑角色失败');
}
} catch (error) {
console.error('编辑角色失败:', error);
messageManager.error(error instanceof Error ? error.message : '编辑角色失败,请稍后重试', { duration: 3000 });
}
};
const onEdit = (uuid: string, currentName: string) => {
setEditingProfile(uuid);
setEditProfileName(currentName);
};
const onCancelEdit = () => {
setEditingProfile(null);
setEditProfileName('');
};
// 渲染内容区域
const renderContent = () => {
switch (activeTab) {
case 'characters':
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={() => setShowCreateCharacter(true)}
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 }}
>
<PlusIcon className="w-5 h-5" />
<span></span>
</motion.button>
</div>
{/* Create Character Modal */}
<AnimatePresence>
{showCreateCharacter && (
<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-md mx-4"
>
<div className="flex justify-between items-center mb-4">
<h3 className="text-xl font-bold text-gray-900 dark:text-white"></h3>
<button
onClick={() => setShowCreateCharacter(false)}
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>
<input
type="text"
value={newCharacterName}
onChange={(e) => setNewCharacterName(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 className="flex space-x-3">
<button
onClick={() => setShowCreateCharacter(false)}
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={handleCreateCharacter}
disabled={!newCharacterName.trim()}
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"
>
</button>
</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" />
<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 md:grid-cols-2 lg:grid-cols-3 gap-6">
{profiles.map((profile) => (
<CharacterCard
key={profile.uuid}
profile={profile}
skinUrl={profileSkins[profile.uuid]?.url}
isSlim={profileSkins[profile.uuid]?.isSlim}
isEditing={editingProfile === profile.uuid}
editName={editProfileName}
onEdit={onEdit}
onSave={handleEditCharacter}
onCancel={onCancelEdit}
onDelete={handleDeleteCharacter}
onSetActive={handleSetActiveCharacter}
onSelectSkin={setShowSkinSelector}
onEditNameChange={setEditProfileName}
/>
))}
</div>
)}
</motion.div>
);
case 'skins':
return (
<MySkinsTab
skins={mySkins}
onUploadClick={() => setShowUploadSkin(true)}
onToggleVisibility={handleToggleSkinVisibility}
onDelete={handleDeleteSkin}
/>
);
case 'favorites':
return (
<FavoritesTab
skins={favoriteSkins}
onToggleFavorite={handleToggleFavorite}
/>
);
case 'settings':
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"
>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white"></h1>
<div className="space-y-6">
{/* Avatar 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 }}
transition={{ duration: 0.2 }}
>
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center space-x-2">
<PhotoIcon className="w-5 h-5" />
<span></span>
</h3>
<div className="flex items-center space-x-6">
<div className="relative">
{userProfile?.avatar ? (
<img
src={userProfile.avatar}
alt={userProfile.username}
className="w-20 h-20 rounded-full border-4 border-orange-500 shadow-lg"
/>
) : (
<div className="w-20 h-20 bg-gradient-to-br from-orange-400 to-amber-500 rounded-full flex items-center justify-center shadow-lg">
<UserCircleIcon className="w-10 h-10 text-white" />
</div>
)}
<motion.button
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 }}
onClick={() => document.getElementById('avatar-upload')?.click()}
>
<PencilIcon className="w-4 h-4" />
</motion.button>
<input
type="file"
id="avatar-upload"
accept="image/*"
onChange={handleAvatarFileSelect}
className="hidden"
/>
</div>
<div className="flex-1">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
JPGPNG 2MB
</p>
{avatarFile && (
<div className="mb-3">
<p className="text-sm text-green-600 dark:text-green-400">
: {avatarFile.name}
</p>
</div>
)}
{isUploadingAvatar && (
<div className="mb-3">
<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: `${avatarUploadProgress}%` }}
/>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
... {avatarUploadProgress}%
</p>
</div>
)}
<div className="flex space-x-3">
<motion.button
onClick={handleUploadAvatar}
disabled={!avatarFile || isUploadingAvatar}
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 disabled:opacity-50 disabled:cursor-not-allowed"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<CloudArrowUpIcon className="w-4 h-4" />
<span>{isUploadingAvatar ? '上传中...' : '上传头像'}</span>
</motion.button>
{userProfile?.avatar && (
<motion.button
onClick={handleDeleteAvatar}
disabled={isUploadingAvatar}
className="border border-red-500 text-red-500 hover:bg-red-500 hover:text-white px-4 py-2 rounded-xl transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
</motion.button>
)}
</div>
</div>
</div>
</motion.div>
{/* 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 }}
transition={{ duration: 0.2 }}
>
<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>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
</label>
<div className="flex gap-2">
<input
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
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 }}
>
{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客户端连接
</p>
</div>
</div>
</motion.div>
{/* Basic Info */}
<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 }}
transition={{ duration: 0.2 }}
>
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center space-x-2">
<UserIcon className="w-5 h-5" />
<span></span>
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<input
type="text"
value={userProfile?.username || ''}
readOnly
className="w-full 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<input
type="email"
value={userProfile?.email || ''}
readOnly
className="w-full 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<input
type="text"
value={userProfile ? new Date(userProfile.created_at).toLocaleDateString() : ''}
readOnly
className="w-full px-4 py-3 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-xl text-gray-500 dark:text-gray-400"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<input
type="text"
value={userProfile?.last_login_at ? new Date(userProfile.last_login_at).toLocaleString() : '从未登录'}
readOnly
className="w-full px-4 py-3 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-xl text-gray-500 dark:text-gray-400"
/>
</div>
</div>
</motion.div>
{/* Account Actions */}
<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 }}
transition={{ duration: 0.2 }}
>
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center space-x-2">
<Cog6ToothIcon className="w-5 h-5" />
<span></span>
</h3>
<div className="space-y-3">
<motion.button
className="w-full flex items-center justify-between p-3 border border-orange-500 text-orange-500 hover:bg-orange-500 hover:text-white rounded-xl transition-all duration-200"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<span></span>
<EnvelopeIcon className="w-5 h-5" />
</motion.button>
<motion.button
onClick={logout}
className="w-full flex items-center justify-between p-3 border border-red-500 text-red-500 hover:bg-red-500 hover:text-white rounded-xl transition-all duration-200"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<span>退</span>
<ArrowLeftOnRectangleIcon className="w-5 h-5" />
</motion.button>
</div>
</motion.div>
</div>
</motion.div>
);
default:
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"
>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
</h1>
<div className="bg-white/50 dark:bg-gray-800/50 backdrop-blur-lg rounded-2xl p-12 text-center">
<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>
</motion.div>
);
}
};
// 渲染皮肤选择器模态框
const renderSkinSelector = () => {
if (!showSkinSelector) return null;
const currentProfile = profiles.find(p => p.uuid === showSkinSelector);
const availableSkins = mySkins.filter(skin => skin.type === 'SKIN');
return (
<AnimatePresence>
<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-4xl mx-4 max-h-[80vh] overflow-y-auto"
>
<div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-bold text-gray-900 dark:text-white">
"{currentProfile?.name}"
</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>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4 mb-6">
<motion.div
className="aspect-square bg-gray-100 dark:bg-gray-700 rounded-xl flex items-center justify-center cursor-pointer border-2 border-dashed border-gray-300 dark:border-gray-600"
whileHover={{ scale: 1.02 }}
onClick={() => {
// 移除皮肤
if (currentProfile) {
updateProfile(currentProfile.uuid, { skin_id: undefined });
setProfiles(prev => prev.map(p =>
p.uuid === currentProfile.uuid ? { ...p, skin_id: undefined } : p
));
setProfileSkins(prev => {
const newSkins = { ...prev };
delete newSkins[currentProfile.uuid];
return newSkins;
});
setShowSkinSelector(null);
}
}}
>
<div className="text-center">
<XMarkIcon className="w-8 h-8 text-gray-400 mx-auto mb-2" />
<p className="text-sm text-gray-500 dark:text-gray-400"></p>
</div>
</motion.div>
{availableSkins.map((skin) => (
<motion.div
key={skin.id}
className="aspect-square bg-gradient-to-br from-orange-100 to-amber-100 dark:from-gray-700 dark:to-gray-600 rounded-xl overflow-hidden cursor-pointer relative group"
whileHover={{ scale: 1.02 }}
onClick={() => {
// 分配皮肤给角色
if (currentProfile) {
updateProfile(currentProfile.uuid, { skin_id: skin.id });
setProfiles(prev => prev.map(p =>
p.uuid === currentProfile.uuid ? { ...p, skin_id: skin.id } : p
));
setProfileSkins(prev => ({
...prev,
[currentProfile.uuid]: { url: skin.url, isSlim: skin.is_slim }
}));
setShowSkinSelector(null);
}
}}
>
<SkinViewer
skinUrl={skin.url}
isSlim={skin.is_slim}
width={200}
height={200}
className="w-full h-full"
autoRotate={false}
/>
<div className="absolute bottom-0 left-0 right-0 bg-black/50 text-white text-xs p-2 text-center">
{skin.name}
</div>
</motion.div>
))}
</div>
{availableSkins.length === 0 && (
<div className="text-center py-8">
<PhotoIcon className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<p className="text-gray-600 dark:text-gray-400"></p>
</div>
)}
</motion.div>
</motion.div>
</AnimatePresence>
);
};
if (!isAuthenticated) {
return (
<div className="min-h-screen flex items-center justify-center 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">
<div className="text-center">
<motion.div
initial={{ scale: 0, rotate: -180 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ duration: 0.6, type: "spring" }}
className="mb-8"
>
<div className="w-24 h-24 bg-gradient-to-br from-orange-400 via-amber-500 to-orange-600 rounded-3xl flex items-center justify-center shadow-2xl mx-auto">
<UserCircleIcon className="w-12 h-12 text-white" />
</div>
</motion.div>
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-4"></h2>
<p className="text-gray-600 dark:text-gray-400 text-lg"></p>
</div>
</div>
);
}
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center 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">
<div className="text-center">
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
className="mb-8"
>
<div className="w-16 h-16 bg-gradient-to-br from-orange-400 via-amber-500 to-orange-600 rounded-full flex items-center justify-center shadow-xl mx-auto">
<Cog6ToothIcon className="w-8 h-8 text-white" />
</div>
</motion.div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">...</h2>
<p className="text-gray-600 dark:text-gray-400"></p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center 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">
<div className="text-center">
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ duration: 0.6, type: "spring" }}
className="mb-8"
>
<div className="w-24 h-24 bg-gradient-to-br from-red-400 via-red-500 to-red-600 rounded-full flex items-center justify-center shadow-2xl mx-auto">
<XCircleIcon className="w-12 h-12 text-white" />
</div>
</motion.div>
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-4"></h2>
<p className="text-gray-600 dark:text-gray-400 text-lg mb-6">{error}</p>
<motion.button
onClick={loadUserData}
className="bg-gradient-to-r from-orange-500 to-amber-500 text-white px-6 py-3 rounded-xl flex items-center space-x-2 shadow-lg hover:shadow-xl transition-all duration-200 mx-auto"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<ArrowPathIcon className="w-5 h-5" />
<span></span>
</motion.button>
</div>
</div>
);
}
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 z-0">
<div className="absolute -top-40 -right-40 w-80 h-80 bg-gradient-to-br from-orange-400/20 to-amber-400/20 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/20 to-orange-400/20 rounded-full blur-3xl animate-pulse delay-1000"></div>
</div>
<div className="relative">
<div className="flex min-h-screen">
{/* Left Sidebar - 使用CSS自定义属性考虑navbar高度 */}
<motion.div
initial={{ x: -100, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{ duration: 0.5, ease: "easeOut" }}
className="w-80 bg-white/80 dark:bg-gray-800/80 backdrop-blur-lg border-r border-gray-200/50 dark:border-gray-700/50 p-6 fixed left-0 h-screen flex flex-col"
style={{
position: 'fixed',
left: 0,
top: 'var(--navbar-height, 64px)',
height: 'calc(100vh - var(--navbar-height, 64px))',
width: '320px'
}}
>
{/* User Profile Card */}
<UserProfileCard
username={userProfile?.username}
email={userProfile?.email}
avatar={userProfile?.avatar}
skinCount={mySkins.length}
favoriteCount={favoriteSkins.length}
points={userProfile?.points || 0}
/>
{/* Profile Sidebar Component */}
<ProfileSidebar
activeTab={activeTab}
onTabChange={(tab: string) => setActiveTab(tab as 'characters' | 'skins' | 'favorites' | 'settings')}
skinCount={mySkins.length}
favoriteCount={favoriteSkins.length}
profilesCount={profiles.length}
onLogout={logout}
/>
</motion.div>
{/* Right Content Area - 考虑左侧fixed侧栏的空间 */}
<motion.div
initial={{ x: 100, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{ duration: 0.5, ease: "easeOut", delay: 0.1 }}
className="flex-1 p-8 overflow-y-auto ml-80"
style={{ marginLeft: '320px', minHeight: '100vh' }}
>
<AnimatePresence mode="wait">
{renderContent()}
</AnimatePresence>
</motion.div>
</div>
</div>
{/* Upload Skin Modal */}
<UploadSkinModal
isOpen={showUploadSkin}
onClose={() => setShowUploadSkin(false)}
onUpload={async (file, data) => {
setSelectedFile(file);
setNewSkinData(data);
await handleUploadSkin(file, data);
}}
isUploading={isUploading}
uploadProgress={uploadProgress}
/>
{/* Skin Selector Modal */}
{renderSkinSelector()}
</div>
);
}