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

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

101
package-lock.json generated
View File

@@ -47,71 +47,6 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/@auth/prisma-adapter": {
"version": "2.11.1", "version": "2.11.1",
"resolved": "https://registry.npmmirror.com/@auth/prisma-adapter/-/prisma-adapter-2.11.1.tgz", "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==", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5", "@babel/generator": "^7.28.5",
@@ -473,8 +407,7 @@
"version": "0.3.2", "version": "0.3.2",
"resolved": "https://registry.npmmirror.com/@electric-sql/pglite/-/pglite-0.3.2.tgz", "resolved": "https://registry.npmmirror.com/@electric-sql/pglite/-/pglite-0.3.2.tgz",
"integrity": "sha512-zfWWa+V2ViDCY/cmUfRqeWY1yLto+EpxjXnZzenB1TyxsTiXaTWeZFIZw6mac52BsuQm0RjCnisjBtdBaXOI6w==", "integrity": "sha512-zfWWa+V2ViDCY/cmUfRqeWY1yLto+EpxjXnZzenB1TyxsTiXaTWeZFIZw6mac52BsuQm0RjCnisjBtdBaXOI6w==",
"license": "Apache-2.0", "license": "Apache-2.0"
"peer": true
}, },
"node_modules/@electric-sql/pglite-socket": { "node_modules/@electric-sql/pglite-socket": {
"version": "0.0.6", "version": "0.0.6",
@@ -1568,7 +1501,6 @@
"resolved": "https://registry.npmmirror.com/@prisma/client/-/client-7.1.0.tgz", "resolved": "https://registry.npmmirror.com/@prisma/client/-/client-7.1.0.tgz",
"integrity": "sha512-qf7GPYHmS/xybNiSOpzv9wBo+UwqfL2PeyX+08v+KVHDI0AlSCQIh5bBySkH3alu06NX9wy98JEnckhMHoMFfA==", "integrity": "sha512-qf7GPYHmS/xybNiSOpzv9wBo+UwqfL2PeyX+08v+KVHDI0AlSCQIh5bBySkH3alu06NX9wy98JEnckhMHoMFfA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@prisma/client-runtime-utils": "7.1.0" "@prisma/client-runtime-utils": "7.1.0"
}, },
@@ -2151,13 +2083,6 @@
"tslib": "^2.4.0" "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": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", "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", "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@@ -2282,7 +2206,6 @@
"integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==", "integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.48.1", "@typescript-eslint/scope-manager": "8.48.1",
"@typescript-eslint/types": "8.48.1", "@typescript-eslint/types": "8.48.1",
@@ -2788,7 +2711,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -3139,7 +3061,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -3865,7 +3786,6 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -4051,7 +3971,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@rtsao/scc": "^1.1.0", "@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9", "array-includes": "^3.1.9",
@@ -4857,7 +4776,6 @@
"resolved": "https://registry.npmmirror.com/hono/-/hono-4.10.6.tgz", "resolved": "https://registry.npmmirror.com/hono/-/hono-4.10.6.tgz",
"integrity": "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g==", "integrity": "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=16.9.0" "node": ">=16.9.0"
} }
@@ -6212,16 +6130,6 @@
"integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==", "integrity": "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==",
"license": "MIT" "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": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", "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", "resolved": "https://registry.npmmirror.com/preact/-/preact-10.24.3.tgz",
"integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
"url": "https://opencollective.com/preact" "url": "https://opencollective.com/preact"
@@ -6648,7 +6555,6 @@
"integrity": "sha512-dy/3urE4JjhdiW5b09pGjVhGI7kPESK2VlCDrCqeYK5m5SslAtG5FCGnZWP7E8Sdg+Ow1wV2mhJH5RTFL5gEsw==", "integrity": "sha512-dy/3urE4JjhdiW5b09pGjVhGI7kPESK2VlCDrCqeYK5m5SslAtG5FCGnZWP7E8Sdg+Ow1wV2mhJH5RTFL5gEsw==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@prisma/config": "7.1.0", "@prisma/config": "7.1.0",
"@prisma/dev": "0.15.0", "@prisma/dev": "0.15.0",
@@ -6767,7 +6673,6 @@
"resolved": "https://registry.npmmirror.com/react/-/react-19.2.0.tgz", "resolved": "https://registry.npmmirror.com/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -6777,7 +6682,6 @@
"resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.0.tgz", "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@@ -7607,7 +7511,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -7782,7 +7685,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -8098,7 +8000,6 @@
"integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }

View File

@@ -35,9 +35,14 @@ import {
getUserProfile, getUserProfile,
updateUserProfile, updateUserProfile,
uploadTexture, uploadTexture,
getTexture,
generateAvatarUploadUrl,
updateAvatarUrl,
resetYggdrasilPassword,
type Texture, type Texture,
type Profile type Profile
} from '@/lib/api'; } from '@/lib/api';
import SkinViewer from '@/components/SkinViewer';
interface UserProfile { interface UserProfile {
id: number; id: number;
@@ -78,6 +83,11 @@ export default function ProfilePage() {
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false); const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
const [avatarUploadProgress, setAvatarUploadProgress] = useState(0); const [avatarUploadProgress, setAvatarUploadProgress] = useState(0);
const [error, setError] = useState<string | null>(null); 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(); const { user, isAuthenticated, logout } = useAuth();
@@ -104,6 +114,34 @@ export default function ProfilePage() {
const profilesResponse = await getProfiles(); const profilesResponse = await getProfiles();
if (profilesResponse.code === 200) { if (profilesResponse.code === 200) {
setProfiles(profilesResponse.data); 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 { } else {
throw new Error(profilesResponse.message || '获取角色列表失败'); throw new Error(profilesResponse.message || '获取角色列表失败');
} }
@@ -163,6 +201,11 @@ export default function ProfilePage() {
const response = await deleteProfile(uuid); const response = await deleteProfile(uuid);
if (response.code === 200) { if (response.code === 200) {
setProfiles(prev => prev.filter(profile => profile.uuid !== uuid)); setProfiles(prev => prev.filter(profile => profile.uuid !== uuid));
setProfileSkins(prev => {
const newSkins = { ...prev };
delete newSkins[uuid];
return newSkins;
});
alert('角色删除成功!'); alert('角色删除成功!');
} else { } else {
throw new Error(response.message || '删除角色失败'); throw new Error(response.message || '删除角色失败');
@@ -392,9 +435,9 @@ export default function ProfilePage() {
if (!confirm('确定要删除头像吗?')) return; if (!confirm('确定要删除头像吗?')) return;
try { try {
const response = await updateAvatarUrl(''); const response = await updateUserProfile({ avatar: '' });
if (response.code === 200) { if (response.code === 200) {
setUserProfile(prev => prev ? { ...prev, avatar: undefined } : null); setUserProfile(response.data);
alert('头像删除成功!'); alert('头像删除成功!');
} else { } else {
throw new Error(response.message || '删除头像失败'); 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 = { const sidebarVariants = {
hidden: { x: -100, opacity: 0 }, hidden: { x: -100, opacity: 0 },
visible: { x: 0, opacity: 1, transition: { duration: 0.5, ease: "easeOut" as const } } visible: { x: 0, opacity: 1, transition: { duration: 0.5, ease: "easeOut" as const } }
@@ -680,6 +802,118 @@ export default function ProfilePage() {
)} )}
</AnimatePresence> </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 ? ( {profiles.length === 0 ? (
<div className="bg-white/50 dark:bg-gray-800/50 backdrop-blur-lg rounded-2xl p-12 text-center"> <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" /> <UserIcon className="w-16 h-16 text-gray-400 mx-auto mb-4" />
@@ -716,9 +950,22 @@ export default function ProfilePage() {
)} )}
</div> </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="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 className="w-20 h-20 bg-gradient-to-br from-orange-400 to-amber-500 rounded-lg shadow-lg"></div>
</div> </div>
)}
</div>
<div className="flex gap-2"> <div className="flex gap-2">
{!profile.is_active && ( {!profile.is_active && (
@@ -729,6 +976,12 @@ export default function ProfilePage() {
使 使
</button> </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 <button
onClick={() => { onClick={() => {
setEditingProfile(profile.uuid); setEditingProfile(profile.uuid);
@@ -926,10 +1179,30 @@ export default function ProfilePage() {
whileHover={{ scale: 1.02, y: -5 }} whileHover={{ scale: 1.02, y: -5 }}
transition={{ duration: 0.2 }} 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="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="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 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>
)}
{!skin.is_public && ( {!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"> <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 }} whileHover={{ scale: 1.02, y: -5 }}
transition={{ duration: 0.2 }} 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="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="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 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>
)}
<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"> <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" /> <HeartIcon className="w-3 h-3" />
<span></span> <span></span>
@@ -1279,7 +1572,7 @@ export default function ProfilePage() {
</div> </div>
</motion.div> </motion.div>
{/* API Settings */} {/* Yggdrasil Settings */}
<motion.div <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" 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 }} 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"> <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" /> <KeyIcon className="w-5 h-5" />
<span>API设置</span> <span>Yggdrasil设置</span>
</h3> </h3>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Yggdrasil API密钥 Yggdrasil密码
</label> </label>
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
type="password" type={showYggdrasilPassword ? "text" : "password"}
value="your-api-key-here" value={yggdrasilPassword || "点击重置按钮生成新密码"}
readOnly 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" 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 <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 }} whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }} whileTap={{ scale: 0.95 }}
> >
{isResettingYggdrasilPassword ? "重置中..." : "重置密码"}
</motion.button> </motion.button>
</div> </div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1"> <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Minecraft客户端连接 Minecraft客户端连接Yggdrasil认证系统
</p> </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>
</div> </div>
</motion.div> </motion.div>

View File

@@ -5,6 +5,7 @@ import { motion, AnimatePresence } from 'framer-motion';
import { MagnifyingGlassIcon, EyeIcon, HeartIcon, ArrowDownTrayIcon, SparklesIcon, FunnelIcon, ArrowsUpDownIcon } from '@heroicons/react/24/outline'; import { MagnifyingGlassIcon, EyeIcon, HeartIcon, ArrowDownTrayIcon, SparklesIcon, FunnelIcon, ArrowsUpDownIcon } from '@heroicons/react/24/outline';
import { HeartIcon as HeartIconSolid } from '@heroicons/react/24/solid'; import { HeartIcon as HeartIconSolid } from '@heroicons/react/24/solid';
import SkinViewer from '@/components/SkinViewer'; import SkinViewer from '@/components/SkinViewer';
import SkinDetailModal from '@/components/SkinDetailModal';
import { searchTextures, toggleFavorite, type Texture } from '@/lib/api'; import { searchTextures, toggleFavorite, type Texture } from '@/lib/api';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
@@ -18,6 +19,8 @@ export default function SkinsPage() {
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
const [favoritedIds, setFavoritedIds] = useState<Set<number>>(new Set()); const [favoritedIds, setFavoritedIds] = useState<Set<number>>(new Set());
const [selectedTexture, setSelectedTexture] = useState<Texture | null>(null);
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
const sortOptions = ['最新', '最热', '最多下载']; const sortOptions = ['最新', '最热', '最多下载'];
@@ -100,103 +103,88 @@ export default function SkinsPage() {
} }
}, [searchTerm, textureType, sortBy, page]); }, [searchTerm, textureType, sortBy, page]);
useEffect(() => {
loadTextures();
}, [loadTextures]);
// 处理收藏 // 处理收藏
const handleFavorite = async (id: number) => { const handleFavorite = async (textureId: number) => {
if (!isAuthenticated) { if (!isAuthenticated) {
alert('请先登录'); alert('请先登录');
return; return;
} }
try { try {
const response = await toggleFavorite(id); const response = await toggleFavorite(textureId);
if (response.code === 200) { if (response.code === 200) {
setFavoritedIds(prev => { const newFavoritedIds = new Set(favoritedIds);
const newSet = new Set(prev); if (favoritedIds.has(textureId)) {
if (response.data.is_favorited) { newFavoritedIds.delete(textureId);
newSet.add(id);
} else { } else {
newSet.delete(id); newFavoritedIds.add(textureId);
} }
return newSet; setFavoritedIds(newFavoritedIds);
});
// 更新本地数据
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
));
} }
} catch (error) { } catch (error) {
console.error('收藏操作失败:', 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"> const handleDetailView = (texture: Texture) => {
{/* Animated Background - 保持背景但简化 */} setSelectedTexture(texture);
<div className="fixed inset-0 overflow-hidden pointer-events-none"> setIsDetailModalOpen(true);
<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>
<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 <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut" }} transition={{ duration: 0.6 }}
className="mb-8" 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> </h1>
<p className="text-gray-600 dark:text-gray-400"> <p className="text-lg text-gray-600 dark:text-gray-300 max-w-2xl mx-auto">
Minecraft皮肤与披风 Minecraft 3D
</p> </p>
</motion.div> </motion.div>
{/* 重新设计的搜索区域 - 更紧凑专业 */} {/* Search and Filter Section - 更紧凑的设计 */}
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1, duration: 0.5 }} transition={{ delay: 0.2, duration: 0.6 }}
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" className="mb-8"
> >
<div className="flex flex-col lg:flex-row gap-4 items-end"> <div className="flex flex-col lg:flex-row gap-4 items-end">
{/* 搜索框 - 更紧凑 */} {/* 搜索框 - 更紧凑 */}
<div className="flex-1"> <div className="flex-1">
<div className="relative"> <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 <input
type="text" type="text"
placeholder="搜索皮肤披风或作者..." placeholder="搜索皮肤披风..."
value={searchTerm} value={searchTerm}
onChange={(e) => { onChange={(e) => setSearchTerm(e.target.value)}
setSearchTerm(e.target.value); onKeyPress={(e) => e.key === 'Enter' && loadTextures()}
setPage(1); 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"
}}
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"
/> />
</div> </div>
</div> </div>
{/* 类型筛选 - 更紧凑 */} {/* 类型筛选 - 更紧凑 */}
<div className="lg:w-48"> <div className="lg:w-40">
<div className="relative"> <div className="relative">
<FunnelIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" /> <FunnelIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
<select <select
@@ -299,59 +287,71 @@ export default function SkinsPage() {
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }} transition={{ delay: i * 0.1 }}
> >
<div className="bg-gray-200 dark:bg-gray-700 rounded-xl aspect-square mb-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 mb-2"></div> <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4 mb-2"></div>
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-2/3"></div> <div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/2"></div>
</motion.div> </motion.div>
))} ))}
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
{/* Textures Grid - 保持卡片设计但简化 */} {/* Results Grid - 更紧凑 */}
<AnimatePresence> <AnimatePresence>
{!isLoading && ( {!isLoading && textures.length > 0 && (
<motion.div <motion.div
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} 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) => { {textures.map((texture, index) => {
const isFavorited = favoritedIds.has(texture.id); const isFavorited = favoritedIds.has(texture.id);
return ( return (
<motion.div <motion.div
key={texture.id} key={texture.id}
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }} transition={{ delay: index * 0.1 }}
className="group relative" 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预览区域 - 更紧凑 */}
{/* 3D Skin Preview */} <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">
<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 <SkinViewer
skinUrl={texture.url} skinUrl={texture.url}
isSlim={texture.is_slim} isSlim={texture.is_slim}
width={400} width={300}
height={400} height={300}
className="w-full h-full transition-transform duration-300 group-hover:scale-105" className="w-full h-full"
autoRotate={false} autoRotate={true}
walking={false}
/> />
) : (
<div className="absolute inset-0 flex items-center justify-center"> {/* 悬停操作按钮 */}
<div className="text-center"> <div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
<motion.div <div className="flex gap-3">
className="w-24 h-24 mx-auto mb-2 bg-white dark:bg-gray-800 rounded-xl shadow-lg flex items-center justify-center" <motion.button
whileHover={{ scale: 1.1, rotate: 5 }} onClick={() => handleDetailView(texture)}
transition={{ type: 'spring', stiffness: 300 }} 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="详细预览"
> >
<span className="text-xl">🧥</span> <EyeIcon className="w-5 h-5" />
</motion.div> </motion.button>
<p className="text-sm text-gray-600 dark:text-gray-300 font-medium"></p>
<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> </div>
)}
{/* 标签 */} {/* 标签 */}
<div className="absolute top-3 right-3 flex gap-1.5"> <div className="absolute top-3 right-3 flex gap-1.5">
@@ -406,12 +406,13 @@ export default function SkinsPage() {
{/* Action Buttons */} {/* Action Buttons */}
<div className="flex gap-2"> <div className="flex gap-2">
<motion.button <motion.button
onClick={() => window.open(texture.url, '_blank')} 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" 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 }} whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
> >
<EyeIcon className="w-4 h-4 mr-1" />
</motion.button> </motion.button>
<motion.button <motion.button
onClick={() => handleFavorite(texture.id)} onClick={() => handleFavorite(texture.id)}
@@ -431,7 +432,6 @@ export default function SkinsPage() {
</motion.button> </motion.button>
</div> </div>
</div> </div>
</div>
</motion.div> </motion.div>
); );
})} })}
@@ -448,15 +448,24 @@ export default function SkinsPage() {
exit={{ opacity: 0, scale: 0.9 }} exit={{ opacity: 0, scale: 0.9 }}
className="text-center py-16" 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"> <div className="text-6xl mb-4">🎨</div>
<MagnifyingGlassIcon className="w-10 h-10 text-gray-400" /> <h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2"></h3> </h3>
<p className="text-gray-600 dark:text-gray-400"></p> <p className="text-gray-600 dark:text-gray-400">
</p>
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
</div> </div>
{/* 详细预览对话框 */}
<SkinDetailModal
isOpen={isDetailModalOpen}
onClose={handleCloseDetailModal}
texture={selectedTexture}
/>
</div> </div>
); );
} }

View 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>
);
}

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useEffect, useRef, useState } from 'react'; 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 { interface SkinViewerProps {
skinUrl: string; skinUrl: string;
@@ -12,6 +12,9 @@ interface SkinViewerProps {
className?: string; className?: string;
autoRotate?: boolean; autoRotate?: boolean;
walking?: boolean; walking?: boolean;
running?: boolean; // 新增:跑步动画
jumping?: boolean; // 新增:跳跃动画
rotation?: boolean; // 新增:旋转控制
} }
export default function SkinViewer({ export default function SkinViewer({
@@ -23,6 +26,9 @@ export default function SkinViewer({
className = '', className = '',
autoRotate = true, autoRotate = true,
walking = false, walking = false,
running = false,
jumping = false,
rotation = true,
}: SkinViewerProps) { }: SkinViewerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const viewerRef = useRef<SkinViewer3D | null>(null); const viewerRef = useRef<SkinViewer3D | null>(null);
@@ -86,11 +92,11 @@ export default function SkinViewer({
// 设置背景和控制选项 - 参考blessingskin // 设置背景和控制选项 - 参考blessingskin
viewer.background = null; // 透明背景 viewer.background = null; // 透明背景
viewer.autoRotate = false; // 禁用自动旋转 viewer.autoRotate = autoRotate && !walking && !running && !jumping; // 只在无动画时自动旋转
// 禁用所有交互控制 // 设置交互控制
viewer.controls.enableRotate = false; // 禁用旋转控制 viewer.controls.enableRotate = rotation; // 根据参数控制旋转
viewer.controls.enableZoom = false; // 用缩放 viewer.controls.enableZoom = true; // 用缩放
viewer.controls.enablePan = false; // 禁用平移 viewer.controls.enablePan = false; // 禁用平移
console.log('3D皮肤查看器初始化成功'); 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改变时更新 // 当皮肤URL改变时更新
useEffect(() => { useEffect(() => {
@@ -145,7 +181,7 @@ export default function SkinViewer({
} }
} else if (viewerRef.current && !capeUrl && imageLoaded) { } else if (viewerRef.current && !capeUrl && imageLoaded) {
try { try {
viewerRef.current.loadCape(null); viewerRef.current.resetCape();
} catch (error) { } catch (error) {
console.error('移除披风失败:', error); console.error('移除披风失败:', error);
} }

View File

@@ -301,3 +301,15 @@ export async function updateAvatarUrl(avatarUrl: string): Promise<ApiResponse<{
return response.json(); 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();
}