feat: 增加用户皮肤管理功能和Yggdrasil密码重置
- 在用户资料页面添加皮肤选择和管理功能,支持上传、配置和移除皮肤 - 实现Yggdrasil密码重置功能,用户可生成新密码并显示 - 优化皮肤展示和选择界面,增强用户体验 - 更新SkinViewer组件,支持跑步和跳跃动画
This commit is contained in:
101
package-lock.json
generated
101
package-lock.json
generated
@@ -47,71 +47,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@auth/core": {
|
||||
"version": "0.34.3",
|
||||
"resolved": "https://registry.npmmirror.com/@auth/core/-/core-0.34.3.tgz",
|
||||
"integrity": "sha512-jMjY/S0doZnWYNV90x0jmU3B+UcrsfGYnukxYrRbj0CVvGI/MX3JbHsxSrx2d4mbnXaUsqJmAcDfoQWA6r0lOw==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@panva/hkdf": "^1.1.1",
|
||||
"@types/cookie": "0.6.0",
|
||||
"cookie": "0.6.0",
|
||||
"jose": "^5.1.3",
|
||||
"oauth4webapi": "^2.10.4",
|
||||
"preact": "10.11.3",
|
||||
"preact-render-to-string": "5.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@simplewebauthn/browser": "^9.0.1",
|
||||
"@simplewebauthn/server": "^9.0.2",
|
||||
"nodemailer": "^7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@simplewebauthn/browser": {
|
||||
"optional": true
|
||||
},
|
||||
"@simplewebauthn/server": {
|
||||
"optional": true
|
||||
},
|
||||
"nodemailer": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@auth/core/node_modules/cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmmirror.com/cookie/-/cookie-0.6.0.tgz",
|
||||
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@auth/core/node_modules/jose": {
|
||||
"version": "5.10.0",
|
||||
"resolved": "https://registry.npmmirror.com/jose/-/jose-5.10.0.tgz",
|
||||
"integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/@auth/core/node_modules/preact-render-to-string": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz",
|
||||
"integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"pretty-format": "^3.8.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"preact": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@auth/prisma-adapter": {
|
||||
"version": "2.11.1",
|
||||
"resolved": "https://registry.npmmirror.com/@auth/prisma-adapter/-/prisma-adapter-2.11.1.tgz",
|
||||
@@ -211,7 +146,6 @@
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -473,8 +407,7 @@
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmmirror.com/@electric-sql/pglite/-/pglite-0.3.2.tgz",
|
||||
"integrity": "sha512-zfWWa+V2ViDCY/cmUfRqeWY1yLto+EpxjXnZzenB1TyxsTiXaTWeZFIZw6mac52BsuQm0RjCnisjBtdBaXOI6w==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@electric-sql/pglite-socket": {
|
||||
"version": "0.0.6",
|
||||
@@ -1568,7 +1501,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/@prisma/client/-/client-7.1.0.tgz",
|
||||
"integrity": "sha512-qf7GPYHmS/xybNiSOpzv9wBo+UwqfL2PeyX+08v+KVHDI0AlSCQIh5bBySkH3alu06NX9wy98JEnckhMHoMFfA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@prisma/client-runtime-utils": "7.1.0"
|
||||
},
|
||||
@@ -2151,13 +2083,6 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmmirror.com/@types/cookie/-/cookie-0.6.0.tgz",
|
||||
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -2194,7 +2119,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.7.tgz",
|
||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -2282,7 +2206,6 @@
|
||||
"integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.48.1",
|
||||
"@typescript-eslint/types": "8.48.1",
|
||||
@@ -2788,7 +2711,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -3139,7 +3061,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -3865,7 +3786,6 @@
|
||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -4051,7 +3971,6 @@
|
||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
@@ -4857,7 +4776,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/hono/-/hono-4.10.6.tgz",
|
||||
"integrity": "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
@@ -6212,16 +6130,6 @@
|
||||
"integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/oauth4webapi": {
|
||||
"version": "2.17.0",
|
||||
"resolved": "https://registry.npmmirror.com/oauth4webapi/-/oauth4webapi-2.17.0.tgz",
|
||||
"integrity": "sha512-lbC0Z7uzAFNFyzEYRIC+pkSVvDHJTbEW+dYlSBAlCYDe6RxUkJ26bClhk8ocBZip1wfI9uKTe0fm4Ib4RHn6uQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@@ -6608,7 +6516,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/preact/-/preact-10.24.3.tgz",
|
||||
"integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
@@ -6648,7 +6555,6 @@
|
||||
"integrity": "sha512-dy/3urE4JjhdiW5b09pGjVhGI7kPESK2VlCDrCqeYK5m5SslAtG5FCGnZWP7E8Sdg+Ow1wV2mhJH5RTFL5gEsw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@prisma/config": "7.1.0",
|
||||
"@prisma/dev": "0.15.0",
|
||||
@@ -6767,7 +6673,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/react/-/react-19.2.0.tgz",
|
||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -6777,7 +6682,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.0.tgz",
|
||||
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -7607,7 +7511,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -7782,7 +7685,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -8098,7 +8000,6 @@
|
||||
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
315
src/components/SkinDetailModal.tsx
Normal file
315
src/components/SkinDetailModal.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { XMarkIcon, PlayIcon, PauseIcon, ArrowPathIcon, ForwardIcon} from '@heroicons/react/24/outline';
|
||||
import SkinViewer from './SkinViewer';
|
||||
|
||||
interface SkinDetailModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
texture: {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
type: 'SKIN' | 'CAPE';
|
||||
is_slim?: boolean;
|
||||
description?: string;
|
||||
favorite_count?: number;
|
||||
download_count?: number;
|
||||
created_at?: string;
|
||||
uploader?: {
|
||||
username: string;
|
||||
};
|
||||
} | null;
|
||||
}
|
||||
|
||||
export default function SkinDetailModal({ isOpen, onClose, texture }: SkinDetailModalProps) {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentAnimation, setCurrentAnimation] = useState<'idle' | 'walking' | 'running' | 'jumping'>('idle');
|
||||
const [autoRotate, setAutoRotate] = useState(true);
|
||||
const [rotation, setRotation] = useState(true);
|
||||
|
||||
// 重置状态当对话框关闭时
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setIsPlaying(false);
|
||||
setCurrentAnimation('idle');
|
||||
setAutoRotate(true);
|
||||
setRotation(true);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// 键盘事件处理
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isOpen) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
onClose();
|
||||
break;
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
setIsPlaying(!isPlaying);
|
||||
break;
|
||||
case '1':
|
||||
setCurrentAnimation('idle');
|
||||
break;
|
||||
case '2':
|
||||
setCurrentAnimation('walking');
|
||||
break;
|
||||
case '3':
|
||||
setCurrentAnimation('running');
|
||||
break;
|
||||
case '4':
|
||||
setCurrentAnimation('jumping');
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose, isPlaying]);
|
||||
|
||||
if (!texture) return null;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.9, opacity: 0 }}
|
||||
transition={{ type: "spring", damping: 20, stiffness: 300 }}
|
||||
className="relative w-full max-w-6xl h-[90vh] bg-white/95 dark:bg-gray-800/95 rounded-2xl shadow-2xl overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="absolute top-0 left-0 right-0 z-10 bg-gradient-to-r from-white/90 to-gray-50/90 dark:from-gray-800/90 dark:to-gray-700/90 backdrop-blur-md border-b border-gray-200/50 dark:border-gray-600/50 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{texture.name}
|
||||
</h2>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className={`px-3 py-1 text-sm rounded-full font-medium ${
|
||||
texture.type === 'SKIN'
|
||||
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
|
||||
: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300'
|
||||
}`}>
|
||||
{texture.type === 'SKIN' ? '皮肤' : '披风'}
|
||||
</span>
|
||||
{texture.is_slim && (
|
||||
<span className="px-3 py-1 bg-pink-100 text-pink-800 dark:bg-pink-900/30 dark:text-pink-300 text-sm rounded-full font-medium">
|
||||
细臂
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<motion.button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
<XMarkIcon className="w-6 h-6" />
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-full pt-20">
|
||||
{/* 3D 预览区域 */}
|
||||
<div className="flex-1 flex items-center justify-center p-8">
|
||||
<div className="w-full h-full max-w-2xl max-h-2xl">
|
||||
<SkinViewer
|
||||
skinUrl={texture.url}
|
||||
isSlim={texture.is_slim}
|
||||
width={600}
|
||||
height={600}
|
||||
className="w-full h-full rounded-xl shadow-lg"
|
||||
autoRotate={autoRotate}
|
||||
walking={currentAnimation === 'walking'}
|
||||
running={currentAnimation === 'running'}
|
||||
jumping={currentAnimation === 'jumping'}
|
||||
rotation={rotation}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 控制面板 */}
|
||||
<div className="w-80 bg-gray-50/80 dark:bg-gray-900/80 backdrop-blur-md border-l border-gray-200/50 dark:border-gray-600/50 p-6 space-y-6 overflow-y-auto">
|
||||
{/* 动画控制 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<PlayIcon className="w-5 h-5 mr-2" />
|
||||
动画控制
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<motion.button
|
||||
onClick={() => setCurrentAnimation('idle')}
|
||||
className={`p-3 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||
currentAnimation === 'idle'
|
||||
? 'bg-orange-500 text-white shadow-lg'
|
||||
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600'
|
||||
}`}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
静止
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
onClick={() => setCurrentAnimation('walking')}
|
||||
className={`p-3 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||
currentAnimation === 'walking'
|
||||
? 'bg-orange-500 text-white shadow-lg'
|
||||
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600'
|
||||
}`}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
步行
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
onClick={() => setCurrentAnimation('running')}
|
||||
className={`p-3 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||
currentAnimation === 'running'
|
||||
? 'bg-orange-500 text-white shadow-lg'
|
||||
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600'
|
||||
}`}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
跑步
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
onClick={() => setCurrentAnimation('jumping')}
|
||||
className={`p-3 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||||
currentAnimation === 'jumping'
|
||||
? 'bg-orange-500 text-white shadow-lg'
|
||||
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600'
|
||||
}`}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
跳跃
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 视角控制 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<ArrowPathIcon className="w-5 h-5 mr-2" />
|
||||
视角控制
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<motion.button
|
||||
onClick={() => setAutoRotate(!autoRotate)}
|
||||
className={`w-full p-3 rounded-lg text-sm font-medium transition-all duration-200 flex items-center justify-center ${
|
||||
autoRotate
|
||||
? 'bg-blue-500 text-white shadow-lg'
|
||||
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600'
|
||||
}`}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<ArrowPathIcon className="w-4 h-4 mr-2" />
|
||||
{autoRotate ? '停止旋转' : '自动旋转'}
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
onClick={() => setRotation(!rotation)}
|
||||
className={`w-full p-3 rounded-lg text-sm font-medium transition-all duration-200 flex items-center justify-center ${
|
||||
rotation
|
||||
? 'bg-green-500 text-white shadow-lg'
|
||||
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600'
|
||||
}`}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<ForwardIcon className="w-4 h-4 mr-2" />
|
||||
{rotation ? '禁用控制' : '启用控制'}
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 皮肤信息 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
|
||||
<span className="w-5 h-5 mr-2">📋</span>
|
||||
皮肤信息
|
||||
</h3>
|
||||
|
||||
<div className="bg-white/50 dark:bg-gray-800/50 rounded-lg p-4 space-y-3">
|
||||
{texture.description && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">描述</p>
|
||||
<p className="text-sm text-gray-900 dark:text-white">{texture.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-gray-600 dark:text-gray-400">收藏数</p>
|
||||
<p className="font-semibold text-gray-900 dark:text-white">{texture.favorite_count || 0}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-600 dark:text-gray-400">下载数</p>
|
||||
<p className="font-semibold text-gray-900 dark:text-white">{texture.download_count || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{texture.uploader && (
|
||||
<div>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">上传者</p>
|
||||
<p className="font-semibold text-gray-900 dark:text-white">{texture.uploader.username}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{texture.created_at && (
|
||||
<div>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">上传时间</p>
|
||||
<p className="font-semibold text-gray-900 dark:text-white">
|
||||
{new Date(texture.created_at).toLocaleDateString('zh-CN')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 快捷键提示 */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
|
||||
<h4 className="text-sm font-semibold text-blue-800 dark:text-blue-300 mb-2 flex items-center">
|
||||
<span className="w-4 h-4 mr-2">⌨️</span>
|
||||
快捷键
|
||||
</h4>
|
||||
<div className="text-xs text-blue-700 dark:text-blue-400 space-y-1">
|
||||
<p><kbd className="px-1 py-0.5 bg-white/50 dark:bg-gray-800/50 rounded">空格</kbd> 播放/暂停</p>
|
||||
<p><kbd className="px-1 py-0.5 bg-white/50 dark:bg-gray-800/50 rounded">1</kbd> 静止</p>
|
||||
<p><kbd className="px-1 py-0.5 bg-white/50 dark:bg-gray-800/50 rounded">2</kbd> 步行</p>
|
||||
<p><kbd className="px-1 py-0.5 bg-white/50 dark:bg-gray-800/50 rounded">3</kbd> 跑步</p>
|
||||
<p><kbd className="px-1 py-0.5 bg-white/50 dark:bg-gray-800/50 rounded">4</kbd> 跳跃</p>
|
||||
<p><kbd className="px-1 py-0.5 bg-white/50 dark:bg-gray-800/50 rounded">ESC</kbd> 关闭</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { SkinViewer as SkinViewer3D, WalkingAnimation, FunctionAnimation } from 'skinview3d';
|
||||
import { SkinViewer as SkinViewer3D, WalkingAnimation, RunningAnimation, FlyingAnimation, IdleAnimation } from 'skinview3d';
|
||||
|
||||
interface SkinViewerProps {
|
||||
skinUrl: string;
|
||||
@@ -12,6 +12,9 @@ interface SkinViewerProps {
|
||||
className?: string;
|
||||
autoRotate?: boolean;
|
||||
walking?: boolean;
|
||||
running?: boolean; // 新增:跑步动画
|
||||
jumping?: boolean; // 新增:跳跃动画
|
||||
rotation?: boolean; // 新增:旋转控制
|
||||
}
|
||||
|
||||
export default function SkinViewer({
|
||||
@@ -23,6 +26,9 @@ export default function SkinViewer({
|
||||
className = '',
|
||||
autoRotate = true,
|
||||
walking = false,
|
||||
running = false,
|
||||
jumping = false,
|
||||
rotation = true,
|
||||
}: SkinViewerProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const viewerRef = useRef<SkinViewer3D | null>(null);
|
||||
@@ -86,11 +92,11 @@ export default function SkinViewer({
|
||||
|
||||
// 设置背景和控制选项 - 参考blessingskin
|
||||
viewer.background = null; // 透明背景
|
||||
viewer.autoRotate = false; // 禁用自动旋转
|
||||
viewer.autoRotate = autoRotate && !walking && !running && !jumping; // 只在无动画时自动旋转
|
||||
|
||||
// 禁用所有交互控制
|
||||
viewer.controls.enableRotate = false; // 禁用旋转控制
|
||||
viewer.controls.enableZoom = false; // 禁用缩放
|
||||
// 设置交互控制
|
||||
viewer.controls.enableRotate = rotation; // 根据参数控制旋转
|
||||
viewer.controls.enableZoom = true; // 启用缩放
|
||||
viewer.controls.enablePan = false; // 禁用平移
|
||||
|
||||
console.log('3D皮肤查看器初始化成功');
|
||||
@@ -112,7 +118,37 @@ export default function SkinViewer({
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [skinUrl, capeUrl, isSlim, width, height, autoRotate, walking, imageLoaded, hasError]);
|
||||
}, [skinUrl, capeUrl, isSlim, width, height, autoRotate, walking, running, jumping, rotation, imageLoaded, hasError]);
|
||||
|
||||
// 控制动画效果 - 参考 Blessing Skin 的实现
|
||||
useEffect(() => {
|
||||
if (!viewerRef.current) return;
|
||||
|
||||
const viewer = viewerRef.current;
|
||||
|
||||
// 根据优先级设置动画 - 参考 Blessing Skin 的 animationFactories
|
||||
if (running) {
|
||||
// 跑步动画
|
||||
viewer.animation = new RunningAnimation();
|
||||
console.log('启用跑步动画');
|
||||
} else if (walking) {
|
||||
// 普通步行动画
|
||||
viewer.animation = new WalkingAnimation();
|
||||
console.log('启用步行动画');
|
||||
} else if (jumping) {
|
||||
// 飞行动画作为跳跃
|
||||
viewer.animation = new FlyingAnimation();
|
||||
console.log('启用跳跃动画');
|
||||
} else {
|
||||
// 静止动画
|
||||
viewer.animation = new IdleAnimation();
|
||||
console.log('启用静止动画');
|
||||
}
|
||||
|
||||
// 更新自动旋转状态
|
||||
viewer.autoRotate = autoRotate && !walking && !running && !jumping;
|
||||
|
||||
}, [walking, running, jumping, autoRotate]);
|
||||
|
||||
// 当皮肤URL改变时更新
|
||||
useEffect(() => {
|
||||
@@ -145,7 +181,7 @@ export default function SkinViewer({
|
||||
}
|
||||
} else if (viewerRef.current && !capeUrl && imageLoaded) {
|
||||
try {
|
||||
viewerRef.current.loadCape(null);
|
||||
viewerRef.current.resetCape();
|
||||
} catch (error) {
|
||||
console.error('移除披风失败:', error);
|
||||
}
|
||||
|
||||
@@ -301,3 +301,15 @@ export async function updateAvatarUrl(avatarUrl: string): Promise<ApiResponse<{
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// 重置Yggdrasil密码
|
||||
export async function resetYggdrasilPassword(): Promise<ApiResponse<{
|
||||
password: string;
|
||||
}>> {
|
||||
const response = await fetch(`${API_BASE_URL}/user/yggdrasil-password/reset`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user