feat: 增加用户皮肤管理功能和Yggdrasil密码重置
- 在用户资料页面添加皮肤选择和管理功能,支持上传、配置和移除皮肤 - 实现Yggdrasil密码重置功能,用户可生成新密码并显示 - 优化皮肤展示和选择界面,增强用户体验 - 更新SkinViewer组件,支持跑步和跳跃动画
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user