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"
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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';
|
'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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user