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

1135 lines
46 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 } 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>
);
}