25 Commits

Author SHA1 Message Date
lan
55cf05110a feat: 用 lucide-react 图标替换 emoji,修复所有 lint/构建问题
- 用 Carrot/Palette/Shirt/AlertTriangle 替换 CS/C/🎨/🧥/⚠️
- 清理所有未使用的 imports 和变量
- 修复 useEffect 中同步 setState 问题(改用 rAF / setTimeout)
- 修复 no-unescaped-entities、匿名导出等 lint 错误
- 为 useSearchParams 添加 Suspense 边界(修复构建)
- 0 errors, 0 warnings
2026-07-09 17:15:59 +08:00
lan
38f11445ba fix(ui): improve authentication UX and refine skin viewer components
- Add loading states to `ProfilePage` and `Navbar` to prevent flickering during authentication checks
- Implement animation pause/resume functionality in `SkinDetailModal` and `SkinViewer`
- Simplify `PageTransition` component by removing unnecessary complexity and state
- Refactor `SkinDetailModal` to use a more robust state management for animations
- Adjust `CharacterCard` skin dimensions for better layout consistency
- Clean up unused imports and redundant animation variants in several components
2026-07-09 17:14:36 +08:00
268344c357 fix: 修复前端多项问题并完善认证流程
- 修复 AuthContext 硬编码 localhost:8080,改用 api.ts 统一的 API_BASE_URL(走环境变量/代理,生产可部署)
- 修复注册页发送验证码绕过 api.ts 直接 raw fetch 的问题,改用统一的 sendVerificationCode
- 修复 SliderCaptcha 验证成功后 captchaId 未透传导致注册人机验证失效的断链
- 修复头像上传契约:预签名URL方式(/user/avatar/upload-url)改为直传(/user/avatar/upload)
- 移除后端不支持的'设置活跃角色'功能(/profile/:uuid/activate)及相关死代码
- 修复分页字段不一致:新增 normalizePaginatedData 兼容后端 per_page 响应
- 强化邮箱正则校验(RFC 标准,禁止连续点/首尾点,TLD>=2字母)
- 移除首页指向无效 /api 页面的'查看API文档'按钮
- 新增 /register /login /signup 重定向路由,统一指向 /auth?mode=
- 同步 API文档.md
2026-07-08 17:36:09 +08:00
lan
fdd1d0c17b fix: 修复ci缓存导致的错误 2026-02-24 17:07:48 +08:00
lan
42c2fb4ce3 fix: 修复ci中出现的错误 2026-02-24 12:50:38 +08:00
lan
2e85be4657 chore: remove unnecessary QEMU setup for single platform build 2026-02-24 11:27:29 +08:00
lan
dad28881ed chore: add Docker configuration and Gitea CI/CD workflow 2026-02-24 11:26:38 +08:00
lan
0c6c0ae1ac fix: 修复了大写导致的不能正常上传镜像 2026-02-24 11:18:00 +08:00
lan
2124790c8d feat: add Docker support and texture deletion functionality
Add Docker configuration with standalone output mode for containerized
deployment. Implement texture deletion API with proper error handling
and user feedback. Fix skin viewer sizing issues by using explicit
dimensions and removing conflicting layout properties. Add captcha
ID parameter to registration flow. Improve profile page UX including
Yggdrasil password reset display and character card editing controls.
2026-02-24 11:09:37 +08:00
f5455afaf2 Merge pull request '解决了不跟手问题' (#8) from feature/slide-captcha into main
Reviewed-on: CarrotSkin/carrotskin#8
2026-02-15 00:26:04 +08:00
eed6920d4a 移除了滑动验证码的延迟渲染 2026-02-14 00:11:00 +08:00
00984b6d67 移除了滑动验证码的延迟渲染 2026-02-13 23:59:56 +08:00
344cae80af Merge pull request '添加了滑动验证码组件' (#7) from feature/slide-captcha into main
Reviewed-on: CarrotSkin/carrotskin#7
2026-02-12 23:08:56 +08:00
321b32e312 添加了滑动验证码组件 2026-02-12 20:54:06 +08:00
914ea7524b Merge pull request 'WIP:合并滑动验证码于注册' (#6) from feature/slide-captcha into main
Reviewed-on: CarrotSkin/carrotskin#6
2026-01-22 21:23:49 +08:00
33342d1a85 集成了滑动验证码组件于注册界面 2026-01-22 20:52:08 +08:00
acf04cad9b 添加了滑动验证码组件 2026-01-22 20:44:19 +08:00
LostSalt
87a8042b77 添加了滑动验证码组件 2026-01-20 01:18:26 +08:00
LostSalt
f3dc7cda21 添加了滑动验证码组件 2026-01-20 01:13:56 +08:00
4d3f34e1c8 Merge pull request '移除神必谷歌字体' (#5) from uNagi/carrotskin:main into main
Reviewed-on: CarrotSkin/carrotskin#5
2026-01-19 21:23:40 +08:00
Mikuisnotavailable
d830b73770 移除神必谷歌字体 2026-01-19 20:08:54 +08:00
6b7a057cb4 修复:主界面下拉出现两个重复的回到页面顶部按钮。注释了NavBar.tsx中的按钮,保留并修改了scrollToTop.tsx中的按钮样式。 2026-01-13 20:32:13 +08:00
f1e8437d31 更新了package-lock.json(npm自动更新) 2026-01-13 17:49:22 +08:00
0c7a54fb1f 修改了css文件头的过时导包语法,在gitignore中添加排除vscode工作区文件 2026-01-13 17:23:04 +08:00
fba619d884 Merge pull request 'main' (#4) from uNagi/carrotskin:main into main
Reviewed-on: CarrotSkin/carrotskin#4
2026-01-11 18:54:00 +08:00
42 changed files with 2176 additions and 1671 deletions

41
.dockerignore Normal file
View File

@@ -0,0 +1,41 @@
# 依赖
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Next.js 构建产物
.next
out
# 测试
coverage
.nyc_output
# IDE
.vscode
.idea
*.swp
*.swo
*~
# 操作系统
.DS_Store
Thumbs.db
# Git
.git
.gitignore
# Docker
Dockerfile
.dockerignore
# 文档
docs
*.md
# 其他
.env
.env.local
.env.*.local

View File

@@ -0,0 +1,85 @@
name: Build and Push Docker Image
on:
push:
branches:
- main
- master
- develop
pull_request:
branches:
- main
- master
workflow_dispatch:
env:
REGISTRY: code.littlelan.cn
IMAGE_NAME: carrotskin/carrotskin
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
# main/master 分支标记为 latest
type=raw,value=latest,enable={{is_default_branch}}
# 所有分支的标签
type=ref,event=branch
# Git tag 时创建版本标签(如 1.0.0, 1.0
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
# 每次构建的 SHA 标签
type=sha
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64
provenance: false
# 禁用 buildcache 以避免 413 错误
# cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
# cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
- name: Show image tags
run: |
echo "Built and pushed image with tags:"
echo "${{ steps.meta.outputs.tags }}"
echo ""
echo "Image digest: ${{ steps.meta.outputs.digest }}"
- name: Summary
if: always()
run: |
echo "## Docker Image Build Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Image:** ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" >> $GITHUB_STEP_SUMMARY
echo "**Tags:**" >> $GITHUB_STEP_SUMMARY
echo "${{ steps.meta.outputs.tags }}" >> $GITHUB_STEP_SUMMARY
echo "**Digest:** ${{ steps.meta.outputs.digest }}" >> $GITHUB_STEP_SUMMARY

3
.gitignore vendored
View File

@@ -1,5 +1,8 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# VS Code Workspace File
*.code-workspace
# dependencies
/node_modules
/.pnp

8
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,8 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

9
.idea/carrotskin.iml generated Normal file
View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@@ -0,0 +1,9 @@
<component name="libraryTable">
<library name="MPLUSRounded1c-Regular.typeface.json">
<CLASSES>
<root url="jar://$PROJECT_DIR$/node_modules/three/examples/fonts/MPLUSRounded1c/MPLUSRounded1c-Regular.typeface.json.zip!/" />
</CLASSES>
<JAVADOC />
<SOURCES />
</library>
</component>

6
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" project-jdk-name="openjdk-24" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/carrotskin.iml" filepath="$PROJECT_DIR$/.idea/carrotskin.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

File diff suppressed because it is too large Load Diff

49
Dockerfile Normal file
View File

@@ -0,0 +1,49 @@
# 构建阶段
FROM node:alpine AS builder
# 设置工作目录
WORKDIR /app
# 复制 package 文件
COPY package*.json ./
# 安装所有依赖(包括 devDependencies
RUN npm ci
# 复制源代码
COPY . .
# 构建应用
RUN npm run build
# 生产阶段
FROM node:alpine AS runner
# 设置工作目录
WORKDIR /app
# 创建非 root 用户
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# 复制构建产物standalone 模式)
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
# 设置正确的权限
RUN chown -R nextjs:nodejs /app
# 切换到非 root 用户
USER nextjs
# 暴露端口
EXPOSE 3000
# 设置环境变量
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# 启动应用
CMD ["node", "server.js"]

View File

@@ -1,7 +1,15 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: 'standalone',
rewrites: async () => {
return [
{
source: '/api/v1/:path*',
destination: 'http://localhost:8080/api/v1/:path*',
},
];
},
};
export default nextConfig;

116
package-lock.json generated
View File

@@ -13,6 +13,7 @@
"@heroicons/react": "^2.2.0",
"@prisma/client": "^7.1.0",
"@types/three": "^0.181.0",
"axios": "^1.13.2",
"framer-motion": "^12.23.25",
"lucide-react": "^0.555.0",
"next": "^16.1.1",
@@ -2955,6 +2956,12 @@
"node": ">= 0.4"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/available-typed-arrays": {
"version": "1.0.7",
"resolved": "https://registry.npmmirror.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -2990,6 +2997,17 @@
"node": ">=4"
}
},
"node_modules/axios": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmmirror.com/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -3125,7 +3143,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -3272,6 +3289,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz",
@@ -3467,6 +3496,15 @@
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
"license": "MIT"
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/denque/-/denque-2.1.0.tgz",
@@ -3521,7 +3559,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
@@ -3652,7 +3689,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -3662,7 +3698,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -3700,7 +3735,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@@ -3713,7 +3747,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -4362,6 +4395,26 @@
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmmirror.com/for-each/-/for-each-0.3.5.tgz",
@@ -4394,6 +4447,22 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/framer-motion": {
"version": "12.23.25",
"resolved": "https://registry.npmmirror.com/framer-motion/-/framer-motion-12.23.25.tgz",
@@ -4425,7 +4494,6 @@
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -4495,7 +4563,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -4526,7 +4593,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
@@ -4631,7 +4697,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -4715,7 +4780,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -4728,7 +4792,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@@ -4744,7 +4807,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -5816,7 +5878,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -5852,6 +5913,27 @@
"node": ">=8.6"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.2.tgz",
@@ -6611,6 +6693,12 @@
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC"
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz",

View File

@@ -14,6 +14,7 @@
"@heroicons/react": "^2.2.0",
"@prisma/client": "^7.1.0",
"@types/three": "^0.181.0",
"axios": "^1.13.2",
"framer-motion": "^12.23.25",
"lucide-react": "^0.555.0",
"next": "^16.1.1",

View File

@@ -1,5 +1,7 @@
export default {
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
}
};
export default config;

View File

@@ -1,14 +1,23 @@
'use client';
import { useState, useEffect } from 'react';
import { useState, useEffect, Suspense } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import { motion, AnimatePresence } from 'framer-motion';
import { EyeIcon, EyeSlashIcon, CheckCircleIcon, XCircleIcon } from '@heroicons/react/24/outline';
import { useAuth } from '@/contexts/AuthContext';
import { errorManager } from '@/components/ErrorNotification';
import SliderCaptcha from '@/components/SliderCaptcha';
import { XMarkIcon } from '@heroicons/react/24/outline';
import { sendVerificationCode, resetPassword } from '@/lib/api';
export default function AuthPage() {
// 邮箱格式校验local@domain.tld
// - local 段:字母/数字/._%+-,首尾不能是点,不能连续点
// - domain 段:字母/数字/-,每段 1-63 字符,至少一个点
// - TLD至少 2 位字母
const EMAIL_REGEX = /^(?!.*\.\.)[a-zA-Z0-9._%+-]+(?<!\.)@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$/;
function AuthForm() {
const [isLoginMode, setIsLoginMode] = useState(true);
const [formData, setFormData] = useState({
username: '',
@@ -27,9 +36,30 @@ export default function AuthPage() {
const [authError, setAuthError] = useState('');
const [isSendingCode, setIsSendingCode] = useState(false);
const [codeTimer, setCodeTimer] = useState(0);
const [showCaptcha, setShowCaptcha] = useState(false);
const [isCaptchaVerified, setIsCaptchaVerified] = useState(false);
const [captchaId, setCaptchaId] = useState<string | undefined>();
// 忘记密码弹窗相关状态
const [showForgotPassword, setShowForgotPassword] = useState(false);
const [forgotForm, setForgotForm] = useState({ email: '', code: '', newPassword: '' });
const [isSendingForgotCode, setIsSendingForgotCode] = useState(false);
const [forgotCodeTimer, setForgotCodeTimer] = useState(0);
const [isResettingPassword, setIsResettingPassword] = useState(false);
const { login, register } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
// 支持 ?mode=register 直接进入注册模式(来自 /register、/signup 等重定向)
useEffect(() => {
const mode = searchParams.get('mode');
if (mode === 'register') {
setIsLoginMode(false);
} else if (mode === 'login') {
setIsLoginMode(true);
}
}, [searchParams]);
useEffect(() => {
let interval: NodeJS.Timeout;
@@ -84,7 +114,7 @@ export default function AuthPage() {
if (!formData.email.trim()) {
newErrors.email = '邮箱不能为空';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
} else if (!EMAIL_REGEX.test(formData.email)) {
newErrors.email = '请输入有效的邮箱地址';
}
@@ -126,25 +156,14 @@ export default function AuthPage() {
const passwordStrength = getPasswordStrength();
const handleSendCode = async () => {
if (!formData.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
if (!formData.email || !EMAIL_REGEX.test(formData.email)) {
setErrors(prev => ({ ...prev, email: '请输入有效的邮箱地址' }));
return;
}
setIsSendingCode(true);
try {
const response = await fetch('/api/v1/auth/send-code', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: formData.email,
type: 'register'
}),
});
const data = await response.json();
const data = await sendVerificationCode(formData.email, 'register');
if (data.code === 200) {
setCodeTimer(60);
@@ -153,7 +172,7 @@ export default function AuthPage() {
setErrors(prev => ({ ...prev, email: data.message || '发送验证码失败' }));
errorManager.showError(data.message || '发送验证码失败');
}
} catch (error) {
} catch {
setErrors(prev => ({ ...prev, email: '发送验证码失败,请稍后重试' }));
errorManager.showError('发送验证码失败,请稍后重试');
} finally {
@@ -161,6 +180,40 @@ export default function AuthPage() {
}
};
const handleCaptchaVerify = (success: boolean, verifiedCaptchaId?: string) => {
if (success) {
setIsCaptchaVerified(true);
setCaptchaId(verifiedCaptchaId);
setShowCaptcha(false);
// 验证码验证成功后,继续注册流程
handleRegisterAfterCaptcha();
} else {
setIsCaptchaVerified(false);
setShowCaptcha(false);
errorManager.showError('验证码验证失败,请重试');
}
};
const handleRegisterAfterCaptcha = async () => {
setIsLoading(true);
setAuthError('');
try {
await register(formData.username, formData.email, formData.password, formData.verificationCode, captchaId);
errorManager.showSuccess('注册成功欢迎加入CarrotSkin');
router.push('/');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '注册失败,请稍后重试';
setAuthError(errorMessage);
errorManager.showError(errorMessage);
// 注册失败时重置验证码状态
setIsCaptchaVerified(false);
} finally {
setIsLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -192,21 +245,14 @@ export default function AuthPage() {
} else {
if (!validateRegisterForm()) return;
setIsLoading(true);
setAuthError('');
try {
await register(formData.username, formData.email, formData.password, formData.verificationCode);
errorManager.showSuccess('注册成功欢迎加入CarrotSkin');
router.push('/');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '注册失败,请稍后重试';
setAuthError(errorMessage);
errorManager.showError(errorMessage);
} finally {
setIsLoading(false);
// 如果验证码还未验证,显示滑动验证码
if (!isCaptchaVerified) {
setShowCaptcha(true);
return;
}
// 如果验证码已验证,直接进行注册
handleRegisterAfterCaptcha();
}
};
@@ -225,6 +271,80 @@ export default function AuthPage() {
});
};
const openForgotPassword = () => {
setForgotForm({ email: '', code: '', newPassword: '' });
setForgotCodeTimer(0);
setShowForgotPassword(true);
};
const closeForgotPassword = () => {
if (isResettingPassword) return;
setShowForgotPassword(false);
};
const handleSendForgotCode = async () => {
const email = forgotForm.email.trim();
if (!email || !EMAIL_REGEX.test(email)) {
errorManager.showError('请输入有效的邮箱地址');
return;
}
setIsSendingForgotCode(true);
try {
const resp = await sendVerificationCode(email, 'reset_password');
if (resp.code === 200) {
errorManager.showSuccess('验证码已发送到您的邮箱');
setForgotCodeTimer(60);
const timer = setInterval(() => {
setForgotCodeTimer(prev => {
if (prev <= 1) {
clearInterval(timer);
return 0;
}
return prev - 1;
});
}, 1000);
} else {
errorManager.showError(resp.message || '发送验证码失败');
}
} catch (err) {
console.error('发送验证码失败:', err);
errorManager.showError('发送验证码失败,请稍后重试');
} finally {
setIsSendingForgotCode(false);
}
};
const handleResetPassword = async () => {
const { email, code, newPassword } = forgotForm;
if (!email.trim() || !EMAIL_REGEX.test(email.trim())) {
errorManager.showError('请输入有效的邮箱地址');
return;
}
if (!/^\d{6}$/.test(code.trim())) {
errorManager.showError('请输入6位数字验证码');
return;
}
if (newPassword.length < 6 || newPassword.length > 128) {
errorManager.showError('新密码长度需在6-128位之间');
return;
}
setIsResettingPassword(true);
try {
const resp = await resetPassword(email.trim(), code.trim(), newPassword);
if (resp.code === 200) {
errorManager.showSuccess('密码重置成功,请使用新密码登录');
setShowForgotPassword(false);
} else {
errorManager.showError(resp.message || '重置密码失败');
}
} catch (err) {
console.error('重置密码失败:', err);
errorManager.showError('重置密码失败,请稍后重试');
} finally {
setIsResettingPassword(false);
}
};
return (
<div className="min-h-screen flex">
{/* Left Side - Orange Section */}
@@ -589,9 +709,13 @@ export default function AuthPage() {
</span>
</label>
<Link href="/forgot-password" className="text-sm text-orange-500 hover:text-orange-600 transition-colors">
<button
type="button"
onClick={openForgotPassword}
className="text-sm text-orange-500 hover:text-orange-600 transition-colors"
>
</Link>
</button>
</motion.div>
)}
</AnimatePresence>
@@ -616,13 +740,21 @@ export default function AuthPage() {
/>
<span className="text-sm text-gray-600 dark:text-gray-400">
<Link href="/terms" className="text-orange-500 hover:text-orange-600 underline ml-1">
<button
type="button"
onClick={(e) => { e.preventDefault(); errorManager.showError('服务条款页面建设中'); }}
className="text-orange-500 hover:text-orange-600 underline ml-1"
>
</Link>
</button>
<Link href="/privacy" className="text-orange-500 hover:text-orange-600 underline ml-1">
<button
type="button"
onClick={(e) => { e.preventDefault(); errorManager.showError('隐私政策页面建设中'); }}
className="text-orange-500 hover:text-orange-600 underline ml-1"
>
</Link>
</button>
</span>
</label>
{errors.agreeToTerms && (
@@ -727,6 +859,111 @@ export default function AuthPage() {
</motion.div>
</motion.div>
</div>
{/* Slider Captcha Component */}
{showCaptcha && (
<SliderCaptcha
onVerify={handleCaptchaVerify}
onClose={() => setShowCaptcha(false)}
/>
)}
{/* Forgot Password Modal */}
{showForgotPassword && (
<div
className="fixed inset-0 bg-black/50 flex items-center justify-center z-[9999] p-4"
onClick={closeForgotPassword}
>
<motion.div
initial={{ scale: 0.9, y: 20 }}
animate={{ scale: 1, y: 0 }}
className="bg-white dark:bg-gray-800 rounded-2xl p-6 w-full max-w-md"
onClick={e => e.stopPropagation()}
>
<div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-bold text-gray-900 dark:text-white"></h3>
<button
type="button"
onClick={closeForgotPassword}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-50"
disabled={isResettingPassword}
>
<XMarkIcon className="w-6 h-6" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<input
type="email"
value={forgotForm.email}
onChange={e => setForgotForm(prev => ({ ...prev, email: e.target.value }))}
placeholder="请输入注册时使用的邮箱"
disabled={isResettingPassword}
className="w-full px-4 py-3 bg-white/50 dark:bg-gray-700/50 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-orange-500 focus:border-transparent transition-all duration-200 disabled:opacity-50"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<div className="flex gap-2">
<input
type="text"
maxLength={6}
value={forgotForm.code}
onChange={e => setForgotForm(prev => ({ ...prev, code: e.target.value.replace(/\D/g, '') }))}
placeholder="6位数字验证码"
disabled={isResettingPassword}
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 disabled:opacity-50"
/>
<button
type="button"
onClick={handleSendForgotCode}
disabled={isSendingForgotCode || forgotCodeTimer > 0 || isResettingPassword}
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 whitespace-nowrap"
>
{forgotCodeTimer > 0 ? `${forgotCodeTimer}s` : isSendingForgotCode ? '发送中...' : '发送验证码'}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<input
type="password"
value={forgotForm.newPassword}
onChange={e => setForgotForm(prev => ({ ...prev, newPassword: e.target.value }))}
placeholder="6-128位新密码"
disabled={isResettingPassword}
className="w-full px-4 py-3 bg-white/50 dark:bg-gray-700/50 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-orange-500 focus:border-transparent transition-all duration-200 disabled:opacity-50"
/>
</div>
<button
type="button"
onClick={handleResetPassword}
disabled={isResettingPassword}
className="w-full bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white font-semibold py-3 rounded-xl shadow-lg transition-all duration-200 disabled:opacity-50"
>
{isResettingPassword ? '重置中...' : '重置密码'}
</button>
</div>
</motion.div>
</div>
)}
</div>
);
}
export default function AuthPage() {
return (
<Suspense fallback={<div className="min-h-screen flex items-center justify-center">Loading...</div>}>
<AuthForm />
</Suspense>
);
}

View File

@@ -1,13 +1,13 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
@import "tailwindcss";
@tailwind base;
/* @tailwind base;
@tailwind components;
@tailwind utilities;
@tailwind utilities; */
:root {
--background: #ffffff;
--foreground: #171717;
--navbar-height: 64px; /* 与pt-16对应 */
--navbar-height: 64px;
/* 与pt-16对应 */
--primary-orange: #f97316;
--primary-orange-dark: #ea580c;
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
@@ -25,7 +25,7 @@
body {
color: var(--foreground);
background: var(--background);
font-family: 'Inter', Arial, Helvetica, sans-serif;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
scroll-behavior: smooth;
}
@@ -159,6 +159,7 @@ body {
/* 现代布局解决方案 */
@layer utilities {
/* 全屏减去navbar高度 */
.h-screen-nav {
height: calc(100vh - var(--navbar-height));
@@ -259,18 +260,23 @@ body {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
@keyframes float {
0%, 100% {
0%,
100% {
transform: translateY(0px);
}
50% {
transform: translateY(-10px);
}
@@ -280,6 +286,7 @@ body {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
@@ -290,6 +297,7 @@ body {
transform: translateY(30px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
@@ -301,6 +309,7 @@ body {
transform: translateY(-30px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
@@ -312,6 +321,7 @@ body {
transform: translateX(-30px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
@@ -323,6 +333,7 @@ body {
transform: translateX(30px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
@@ -334,6 +345,7 @@ body {
transform: scale(0.9);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
@@ -345,6 +357,7 @@ body {
transform: scale(1);
opacity: 1;
}
to {
transform: scale(0.9);
opacity: 0;
@@ -378,23 +391,19 @@ body {
/* 加载状态样式 */
.loading-shimmer {
background: linear-gradient(
90deg,
background: linear-gradient(90deg,
#f0f0f0 0%,
#e0e0e0 50%,
#f0f0f0 100%
);
#f0f0f0 100%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.dark .loading-shimmer {
background: linear-gradient(
90deg,
background: linear-gradient(90deg,
#374151 0%,
#4b5563 50%,
#374151 100%
);
#374151 100%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
@@ -426,7 +435,10 @@ body {
/* 响应式动效 */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
@@ -435,6 +447,7 @@ body {
/* 触摸设备优化 */
@media (hover: none) and (pointer: coarse) {
.btn-carrot:hover,
.btn-carrot-outline:hover,
.card-minecraft:hover {

View File

@@ -1,5 +1,4 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import Navbar from "@/components/Navbar";
import { AuthProvider } from "@/contexts/AuthContext";
@@ -9,12 +8,6 @@ import { ErrorNotificationContainer } from "@/components/ErrorNotification";
import ScrollToTop from "@/components/ScrollToTop";
import PageTransition from "@/components/PageTransition";
const inter = Inter({
subsets: ["latin"],
weight: ['100', '200', '300', '400', '500', '600', '700', '800', '900'],
display: 'swap',
});
export const metadata: Metadata = {
title: "CarrotSkin - 现代化Minecraft Yggdrasil皮肤站",
description: "新一代Minecraft Yggdrasil皮肤站为创作者打造的现代化皮肤管理平台",
@@ -34,7 +27,7 @@ export default function RootLayout({
}>) {
return (
<html lang="zh-CN">
<body className={inter.className}>
<body>
<AuthProvider>
<Navbar />
<PageTransition>

6
src/app/login/page.tsx Normal file
View File

@@ -0,0 +1,6 @@
import { redirect } from 'next/navigation';
// /login 兼容入口:重定向到统一认证页并默认进入登录模式
export default function LoginPage() {
redirect('/auth?mode=login');
}

View File

@@ -9,10 +9,10 @@ import {
CloudArrowUpIcon,
ShareIcon,
CubeIcon,
UserGroupIcon,
SparklesIcon,
RocketLaunchIcon
} from '@heroicons/react/24/outline';
import { Carrot } from 'lucide-react';
export default function Home() {
const { scrollYProgress } = useScroll();
@@ -103,7 +103,7 @@ export default function Home() {
>
<div className="relative">
<div className="w-24 h-24 bg-gradient-to-br from-orange-400 via-amber-500 to-orange-600 rounded-3xl flex items-center justify-center shadow-2xl">
<span className="text-4xl font-bold text-white">CS</span>
<Carrot className="w-12 h-12 text-white" />
</div>
<motion.div
className="absolute -inset-2 bg-gradient-to-br from-orange-400/30 to-amber-500/30 rounded-3xl blur-lg"
@@ -291,14 +291,6 @@ export default function Home() {
<span></span>
<ArrowRightIcon className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
</Link>
<Link
href="/api"
className="border-2 border-white/30 text-white hover:bg-white/10 font-bold py-4 px-8 rounded-2xl transition-all duration-300 inline-flex items-center space-x-2"
>
<span>API文档</span>
<UserGroupIcon className="w-5 h-5" />
</Link>
</div>
</motion.div>
</section>

View File

@@ -12,14 +12,10 @@ import {
XMarkIcon,
UserIcon,
PhotoIcon,
HeartIcon,
TrashIcon,
PencilIcon,
KeyIcon,
EnvelopeIcon,
CloudArrowUpIcon,
EyeIcon,
ArrowDownTrayIcon,
ArrowLeftOnRectangleIcon
} from '@heroicons/react/24/outline';
import {
@@ -30,14 +26,16 @@ import {
createProfile,
updateProfile,
deleteProfile,
setActiveProfile,
getUserProfile,
updateUserProfile,
uploadTexture,
getTexture,
generateAvatarUploadUrl,
updateAvatarUrl,
uploadAvatar,
resetYggdrasilPassword,
deleteTexture,
updateTexture,
sendVerificationCode,
changeEmail,
type Texture,
type Profile
} from '@/lib/api';
@@ -76,14 +74,14 @@ export default function ProfilePage() {
const [showCreateCharacter, setShowCreateCharacter] = useState(false);
const [showUploadSkin, setShowUploadSkin] = useState(false);
const [newCharacterName, setNewCharacterName] = useState('');
const [newSkinData, setNewSkinData] = useState({
const [, setNewSkinData] = useState({
name: '',
description: '',
type: 'SKIN' as 'SKIN' | 'CAPE',
is_public: false,
is_slim: false
});
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [, setSelectedFile] = useState<File | null>(null);
const [editingProfile, setEditingProfile] = useState<string | null>(null);
const [editProfileName, setEditProfileName] = useState('');
const [uploadProgress, setUploadProgress] = useState(0);
@@ -97,7 +95,14 @@ export default function ProfilePage() {
const [showYggdrasilPassword, setShowYggdrasilPassword] = useState<boolean>(false);
const [isResettingYggdrasilPassword, setIsResettingYggdrasilPassword] = useState<boolean>(false);
const { user, isAuthenticated, logout } = useAuth();
// 更换邮箱流程相关状态
const [showChangeEmailModal, setShowChangeEmailModal] = useState(false);
const [changeEmailForm, setChangeEmailForm] = useState({ newEmail: '', code: '' });
const [isSendingEmailCode, setIsSendingEmailCode] = useState(false);
const [emailCodeCountdown, setEmailCodeCountdown] = useState(0);
const [isChangingEmail, setIsChangingEmail] = useState(false);
const { isAuthenticated, isLoading: isAuthLoading, logout, updateUser } = useAuth();
// 加载用户数据
useEffect(() => {
@@ -178,23 +183,54 @@ export default function ProfilePage() {
};
const handleToggleSkinVisibility = async (skinId: number) => {
try {
const skin = mySkins.find(s => s.id === skinId);
if (!skin) return;
// TODO: 添加更新皮肤API调用
setMySkins(prev => prev.map(skin =>
skin.id === skinId ? { ...skin, is_public: !skin.is_public } : skin
const newIsPublic = !skin.is_public;
// 先乐观更新本地态,失败再回滚
setMySkins(prev => prev.map(s =>
s.id === skinId ? { ...s, is_public: newIsPublic } : s
));
try {
const response = await updateTexture(skinId, { is_public: newIsPublic });
if (response.code === 200) {
// 以后端返回为准
setMySkins(prev => prev.map(s =>
s.id === skinId ? { ...s, is_public: response.data.is_public } : s
));
messageManager.success(newIsPublic ? '已设为公开' : '已设为隐藏', { duration: 2000 });
} else {
// 回滚
setMySkins(prev => prev.map(s =>
s.id === skinId ? { ...s, is_public: skin.is_public } : s
));
messageManager.error(response.message || '切换可见性失败', { duration: 3000 });
}
} catch (error) {
console.error('切换皮肤可见性失败:', error);
setMySkins(prev => prev.map(s =>
s.id === skinId ? { ...s, is_public: skin.is_public } : s
));
messageManager.error('切换可见性失败,请稍后重试', { duration: 3000 });
}
};
const handleDeleteSkin = async (skinId: number) => {
if (!confirm('确定要删除这个皮肤吗?')) return;
try {
const response = await deleteTexture(skinId);
if (response.code === 200) {
setMySkins(prev => prev.filter(skin => skin.id !== skinId));
messageManager.success('皮肤删除成功', { duration: 3000 });
} else {
messageManager.error(response.message || '删除皮肤失败', { duration: 3000 });
}
} catch (error) {
console.error('删除皮肤失败:', error);
messageManager.error('删除皮肤失败', { duration: 3000 });
}
};
const handleToggleFavorite = async (skinId: number) => {
@@ -212,13 +248,6 @@ export default function ProfilePage() {
}
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setSelectedFile(file);
}
};
const handleUploadSkin = async (file: File, data: { name: string; description: string; type: 'SKIN' | 'CAPE'; is_public: boolean; is_slim: boolean }) => {
if (!file || !data.name.trim()) {
messageManager.warning('请选择皮肤文件并输入皮肤名称', { duration: 3000 });
@@ -301,45 +330,24 @@ export default function ProfilePage() {
setAvatarUploadProgress(0);
try {
// 获取上传URL
const uploadUrlResponse = await generateAvatarUploadUrl(avatarFile.name);
if (uploadUrlResponse.code !== 200) {
throw new Error(uploadUrlResponse.message || '获取上传URL失败');
}
const { post_url, form_data, avatar_url } = uploadUrlResponse.data;
// 模拟上传进度
const progressInterval = setInterval(() => {
setAvatarUploadProgress(prev => Math.min(prev + 20, 80));
}, 200);
// 上传文件到预签名URL
const formData = new FormData();
Object.entries(form_data).forEach(([key, value]) => {
formData.append(key, value as string);
});
formData.append('file', avatarFile);
const uploadResponse = await fetch(post_url, {
method: 'POST',
body: formData,
});
if (!uploadResponse.ok) {
throw new Error('文件上传失败');
}
// 直接上传文件到后端,后端写入对象存储并更新用户头像
const response = await uploadAvatar(avatarFile);
clearInterval(progressInterval);
setAvatarUploadProgress(100);
// 更新用户头像URL
const response = await updateAvatarUrl(avatar_url);
if (response.code === 200) {
setUserProfile(prev => prev ? { ...prev, avatar: avatar_url } : null);
const avatarUrl = response.data.avatar_url;
setUserProfile(prev => prev ? { ...prev, avatar: avatarUrl } : null);
updateUser({ avatar: avatarUrl });
messageManager.success('头像上传成功!', { duration: 3000 });
} else {
throw new Error(response.message || '更新头像URL失败');
throw new Error(response.message || '头像上传失败');
}
} catch (error) {
@@ -379,6 +387,8 @@ export default function ProfilePage() {
try {
const response = await resetYggdrasilPassword();
if (response.code === 200) {
setYggdrasilPassword(response.data.password);
setShowYggdrasilPassword(true);
messageManager.success('Yggdrasil密码重置成功请妥善保管新密码。', { duration: 5000 });
} else {
throw new Error(response.message || '重置Yggdrasil密码失败');
@@ -391,6 +401,86 @@ export default function ProfilePage() {
}
};
// 发送更换邮箱的验证码type=change_email
const handleSendChangeEmailCode = async () => {
const email = changeEmailForm.newEmail.trim();
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!email) {
messageManager.warning('请输入新邮箱地址', { duration: 3000 });
return;
}
if (!EMAIL_REGEX.test(email)) {
messageManager.warning('请输入有效的邮箱地址', { duration: 3000 });
return;
}
if (email === userProfile?.email) {
messageManager.warning('新邮箱不能与当前邮箱相同', { duration: 3000 });
return;
}
setIsSendingEmailCode(true);
try {
const resp = await sendVerificationCode(email, 'change_email');
if (resp.code === 200) {
messageManager.success('验证码已发送到新邮箱', { duration: 3000 });
setEmailCodeCountdown(60);
const timer = setInterval(() => {
setEmailCodeCountdown(prev => {
if (prev <= 1) {
clearInterval(timer);
return 0;
}
return prev - 1;
});
}, 1000);
} else {
messageManager.error(resp.message || '发送验证码失败', { duration: 3000 });
}
} catch (err) {
console.error('发送验证码失败:', err);
messageManager.error('发送验证码失败,请稍后重试', { duration: 3000 });
} finally {
setIsSendingEmailCode(false);
}
};
// 提交更换邮箱
const handleChangeEmail = async () => {
const newEmail = changeEmailForm.newEmail.trim();
const code = changeEmailForm.code.trim();
if (!newEmail) {
messageManager.warning('请输入新邮箱地址', { duration: 3000 });
return;
}
if (!/^\d{6}$/.test(code)) {
messageManager.warning('请输入6位数字验证码', { duration: 3000 });
return;
}
setIsChangingEmail(true);
try {
const resp = await changeEmail(newEmail, code);
if (resp.code === 200) {
messageManager.success('邮箱更换成功', { duration: 3000 });
// 同步本地用户信息email 已变化)
setUserProfile(prev => prev ? { ...prev, email: resp.data.email } : prev);
if (updateUser) {
updateUser({ email: resp.data.email });
}
setShowChangeEmailModal(false);
setChangeEmailForm({ newEmail: '', code: '' });
setEmailCodeCountdown(0);
} else {
messageManager.error(resp.message || '更换邮箱失败', { duration: 3000 });
}
} catch (err) {
console.error('更换邮箱失败:', err);
messageManager.error('更换邮箱失败,请稍后重试', { duration: 3000 });
} finally {
setIsChangingEmail(false);
}
};
const handleCreateCharacter = async () => {
if (!newCharacterName.trim()) {
messageManager.warning('请输入角色名称', { duration: 3000 });
@@ -434,25 +524,6 @@ export default function ProfilePage() {
}
};
const handleSetActiveCharacter = async (uuid: string) => {
try {
const response = await setActiveProfile(uuid);
if (response.code === 200) {
setProfiles(prev => prev.map(profile => ({
...profile,
is_active: profile.uuid === uuid
})));
messageManager.success('角色切换成功!', { duration: 3000 });
} else {
throw new Error(response.message || '设置活跃角色失败');
}
} catch (error) {
console.error('设置活跃角色失败:', error);
messageManager.error(error instanceof Error ? error.message : '设置活跃角色失败,请稍后重试', { duration: 3000 });
} finally {
}
};
const handleEditCharacter = async () => {
if (!editProfileName.trim()) {
messageManager.warning('请输入角色名称', { duration: 3000 });
@@ -590,7 +661,6 @@ export default function ProfilePage() {
onSave={handleEditCharacter}
onCancel={onCancelEdit}
onDelete={handleDeleteCharacter}
onSetActive={handleSetActiveCharacter}
onSelectSkin={setShowSkinSelector}
onEditNameChange={setEditProfileName}
/>
@@ -640,6 +710,7 @@ export default function ProfilePage() {
<div className="flex items-center space-x-6">
<div className="relative">
{userProfile?.avatar ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={userProfile.avatar}
alt={userProfile.username}
@@ -837,6 +908,11 @@ export default function ProfilePage() {
className="w-full flex items-center justify-between p-3 border border-orange-500 text-orange-500 hover:bg-orange-500 hover:text-white rounded-xl transition-all duration-200"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={() => {
setChangeEmailForm({ newEmail: '', code: '' });
setEmailCodeCountdown(0);
setShowChangeEmailModal(true);
}}
>
<span></span>
<EnvelopeIcon className="w-5 h-5" />
@@ -899,7 +975,7 @@ export default function ProfilePage() {
>
<div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-bold text-gray-900 dark:text-white">
"{currentProfile?.name}"
&ldquo;{currentProfile?.name}&rdquo;
</h3>
<button
onClick={() => setShowSkinSelector(null)}
@@ -913,19 +989,35 @@ export default function ProfilePage() {
<motion.div
className="aspect-square bg-gray-100 dark:bg-gray-700 rounded-xl flex items-center justify-center cursor-pointer border-2 border-dashed border-gray-300 dark:border-gray-600"
whileHover={{ scale: 1.02 }}
onClick={() => {
// 移除皮肤
if (currentProfile) {
updateProfile(currentProfile.uuid, { skin_id: undefined });
onClick={async () => {
if (!currentProfile) return;
const prevSkinId = currentProfile.skin_id;
// 乐观更新本地态
setProfiles(prev => prev.map(p =>
p.uuid === currentProfile.uuid ? { ...p, skin_id: undefined } : p
));
setProfileSkins(prev => {
const newSkins = { ...prev };
delete newSkins[currentProfile.uuid];
return newSkins;
const next = { ...prev };
delete next[currentProfile.uuid];
return next;
});
setShowSkinSelector(null);
try {
// 显式传 null区别于 undefined 被 JSON.stringify 丢弃),后端据此清空皮肤关联
const resp = await updateProfile(currentProfile.uuid, { skin_id: null });
if (resp.code !== 200) {
// 回滚
setProfiles(prev => prev.map(p =>
p.uuid === currentProfile.uuid ? { ...p, skin_id: prevSkinId } : p
));
messageManager.error(resp.message || '移除皮肤失败', { duration: 3000 });
}
} catch (err) {
console.error('移除皮肤失败:', err);
setProfiles(prev => prev.map(p =>
p.uuid === currentProfile.uuid ? { ...p, skin_id: prevSkinId } : p
));
messageManager.error('移除皮肤失败,请稍后重试', { duration: 3000 });
}
}}
>
@@ -940,10 +1032,10 @@ export default function ProfilePage() {
key={skin.id}
className="aspect-square bg-gradient-to-br from-orange-100 to-amber-100 dark:from-gray-700 dark:to-gray-600 rounded-xl overflow-hidden cursor-pointer relative group"
whileHover={{ scale: 1.02 }}
onClick={() => {
// 分配皮肤给角色
if (currentProfile) {
updateProfile(currentProfile.uuid, { skin_id: skin.id });
onClick={async () => {
if (!currentProfile) return;
const prevSkinId = currentProfile.skin_id;
// 乐观更新
setProfiles(prev => prev.map(p =>
p.uuid === currentProfile.uuid ? { ...p, skin_id: skin.id } : p
));
@@ -952,17 +1044,32 @@ export default function ProfilePage() {
[currentProfile.uuid]: { url: skin.url, isSlim: skin.is_slim }
}));
setShowSkinSelector(null);
try {
const resp = await updateProfile(currentProfile.uuid, { skin_id: skin.id });
if (resp.code !== 200) {
setProfiles(prev => prev.map(p =>
p.uuid === currentProfile.uuid ? { ...p, skin_id: prevSkinId } : p
));
messageManager.error(resp.message || '设置皮肤失败', { duration: 3000 });
}
} catch (err) {
console.error('设置皮肤失败:', err);
setProfiles(prev => prev.map(p =>
p.uuid === currentProfile.uuid ? { ...p, skin_id: prevSkinId } : p
));
messageManager.error('设置皮肤失败,请稍后重试', { duration: 3000 });
}
}}
>
<div className="w-full h-full flex items-center justify-center">
<SkinViewer
skinUrl={skin.url}
isSlim={skin.is_slim}
width={200}
height={200}
className="w-full h-full"
width={180}
height={180}
autoRotate={false}
/>
</div>
<div className="absolute bottom-0 left-0 right-0 bg-black/50 text-white text-xs p-2 text-center">
{skin.name}
</div>
@@ -982,6 +1089,27 @@ export default function ProfilePage() {
);
};
// auth 还在检查 token先显示加载中避免误判为未登录
if (isAuthLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 via-orange-50 to-amber-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
<div className="text-center">
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
className="mb-8"
>
<div className="w-16 h-16 bg-gradient-to-br from-orange-400 via-amber-500 to-orange-600 rounded-full flex items-center justify-center shadow-xl mx-auto">
<Cog6ToothIcon className="w-8 h-8 text-white" />
</div>
</motion.div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">...</h2>
<p className="text-gray-600 dark:text-gray-400"></p>
</div>
</div>
);
}
if (!isAuthenticated) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 via-orange-50 to-amber-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
@@ -1128,6 +1256,116 @@ export default function ProfilePage() {
{/* Skin Selector Modal */}
{renderSkinSelector()}
{/* Change Email Modal */}
{showChangeEmailModal && (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-[9999]"
onClick={() => !isChangingEmail && setShowChangeEmailModal(false)}
>
<motion.div
initial={{ scale: 0.9, y: 20 }}
animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.9, y: 20 }}
className="bg-white dark:bg-gray-800 rounded-2xl p-6 w-full max-w-md mx-4"
onClick={e => e.stopPropagation()}
>
<div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-bold text-gray-900 dark:text-white flex items-center space-x-2">
<EnvelopeIcon className="w-5 h-5" />
<span></span>
</h3>
<button
onClick={() => !isChangingEmail && setShowChangeEmailModal(false)}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-50"
disabled={isChangingEmail}
>
<XMarkIcon className="w-6 h-6" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<input
type="email"
value={userProfile?.email || ''}
readOnly
className="w-full px-4 py-3 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-xl text-gray-500 dark:text-gray-400"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<input
type="email"
value={changeEmailForm.newEmail}
onChange={e => setChangeEmailForm(prev => ({ ...prev, newEmail: e.target.value }))}
placeholder="请输入新邮箱"
disabled={isChangingEmail}
className="w-full px-4 py-3 bg-white/50 dark:bg-gray-700/50 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-orange-500 focus:border-transparent transition-all duration-200 disabled:opacity-50"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<div className="flex gap-2">
<input
type="text"
maxLength={6}
value={changeEmailForm.code}
onChange={e => setChangeEmailForm(prev => ({ ...prev, code: e.target.value.replace(/\D/g, '') }))}
placeholder="6位数字验证码"
disabled={isChangingEmail}
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 disabled:opacity-50"
/>
<motion.button
onClick={handleSendChangeEmailCode}
disabled={isSendingEmailCode || emailCodeCountdown > 0 || isChangingEmail}
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 whitespace-nowrap"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
{emailCodeCountdown > 0 ? `${emailCodeCountdown}s` : isSendingEmailCode ? '发送中...' : '发送验证码'}
</motion.button>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
</p>
</div>
<div className="flex space-x-3 pt-2">
<motion.button
onClick={() => setShowChangeEmailModal(false)}
disabled={isChangingEmail}
className="flex-1 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 px-4 py-2 rounded-xl transition-all duration-200 disabled:opacity-50"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
</motion.button>
<motion.button
onClick={handleChangeEmail}
disabled={isChangingEmail}
className="flex-1 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"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
{isChangingEmail ? '更换中...' : '确认更换'}
</motion.button>
</div>
</div>
</motion.div>
</motion.div>
</AnimatePresence>
)}
</div>
);
}

View File

@@ -0,0 +1,6 @@
import { redirect } from 'next/navigation';
// /register 兼容入口:重定向到统一认证页并默认进入注册模式
export default function RegisterPage() {
redirect('/auth?mode=register');
}

6
src/app/signup/page.tsx Normal file
View File

@@ -0,0 +1,6 @@
import { redirect } from 'next/navigation';
// /signup 兼容入口:重定向到统一认证页并默认进入注册模式
export default function SignupPage() {
redirect('/auth?mode=register');
}

View File

@@ -2,9 +2,8 @@
import { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { MagnifyingGlassIcon, EyeIcon, HeartIcon, ArrowDownTrayIcon, SparklesIcon, FunnelIcon, ArrowsUpDownIcon } from '@heroicons/react/24/outline';
import { HeartIcon as HeartIconSolid } from '@heroicons/react/24/solid';
import SkinViewer from '@/components/SkinViewer';
import { MagnifyingGlassIcon, FunnelIcon, ArrowsUpDownIcon } from '@heroicons/react/24/outline';
import { Palette } from 'lucide-react';
import SkinDetailModal from '@/components/SkinDetailModal';
import SkinCard from '@/components/SkinCard';
import { searchTextures, toggleFavorite, type Texture } from '@/lib/api';
@@ -307,7 +306,7 @@ export default function SkinsPage() {
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
>
{textures.map((texture, index) => {
{textures.map((texture) => {
const isFavorited = favoritedIds.has(texture.id);
return (
@@ -335,7 +334,9 @@ export default function SkinsPage() {
exit={{ opacity: 0, scale: 0.9 }}
className="text-center py-16"
>
<div className="text-6xl mb-4">🎨</div>
<div className="flex justify-center mb-4">
<Palette className="w-16 h-16 text-orange-500" />
</div>
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
</h3>

View File

@@ -1,7 +1,7 @@
'use client';
import { motion, MotionProps } from 'framer-motion';
import { ReactNode, useState, useEffect } from 'react';
import { ReactNode, useState } from 'react';
import { AnimatePresence } from 'framer-motion';
interface EnhancedButtonProps extends Omit<MotionProps, 'onClick'> {
@@ -15,7 +15,6 @@ interface EnhancedButtonProps extends Omit<MotionProps, 'onClick'> {
icon?: ReactNode;
iconPosition?: 'left' | 'right';
ripple?: boolean;
sound?: boolean;
haptic?: boolean;
className?: string;
type?: 'button' | 'submit' | 'reset';
@@ -32,7 +31,6 @@ export default function EnhancedButton({
icon,
iconPosition = 'left',
ripple = true,
sound = true,
haptic = true,
className = '',
type = 'button',
@@ -41,37 +39,6 @@ export default function EnhancedButton({
const [isProcessing, setIsProcessing] = useState(false);
const [ripples, setRipples] = useState<Array<{ id: number; x: number; y: number }>>([]);
// 播放音效
const playSound = (type: 'click' | 'success' | 'error' = 'click') => {
if (!sound) return;
try {
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
const frequencies = {
click: 800,
success: 1000,
error: 400
};
oscillator.frequency.setValueAtTime(frequencies[type], audioContext.currentTime);
oscillator.type = type === 'error' ? 'sawtooth' : 'sine';
gainNode.gain.setValueAtTime(0.1, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2);
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.2);
} catch (error) {
// 忽略音频API错误
}
};
// 触觉反馈
const triggerHaptic = () => {
if (haptic && navigator.vibrate) {
@@ -102,7 +69,6 @@ export default function EnhancedButton({
if (disabled || loading || isProcessing) return;
createRipple(e);
playSound('click');
triggerHaptic();
if (onClick) {
@@ -113,10 +79,8 @@ export default function EnhancedButton({
// 如果返回的是 Promise等待它完成
if (result instanceof Promise) {
await result;
playSound('success');
}
} catch (error) {
playSound('error');
console.error('Button click error:', error);
} finally {
setIsProcessing(false);

View File

@@ -192,7 +192,6 @@ export function ErrorNotification({ message, type = 'error', duration = 5000, on
class ErrorManager {
private static instance: ErrorManager;
private listeners: Array<(notification: ErrorNotificationProps & { id: string }) => void> = [];
private soundEnabled: boolean = true;
static getInstance(): ErrorManager {
if (!ErrorManager.instance) {
@@ -201,51 +200,19 @@ class ErrorManager {
return ErrorManager.instance;
}
private playSound(type: ErrorType) {
if (!this.soundEnabled) return;
// 创建音频反馈
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
const frequencies = {
error: 300,
warning: 400,
success: 600,
info: 500
};
oscillator.frequency.setValueAtTime(frequencies[type], audioContext.currentTime);
oscillator.type = type === 'error' ? 'sawtooth' : 'sine';
gainNode.gain.setValueAtTime(0.1, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.3);
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.3);
}
showError(message: string, duration?: number) {
this.playSound('error');
this.showNotification(message, 'error', duration);
}
showWarning(message: string, duration?: number) {
this.playSound('warning');
this.showNotification(message, 'warning', duration);
}
showSuccess(message: string, duration?: number) {
this.playSound('success');
this.showNotification(message, 'success', duration);
}
showInfo(message: string, duration?: number) {
this.playSound('info');
this.showNotification(message, 'info', duration);
}
@@ -266,10 +233,6 @@ class ErrorManager {
this.listeners = this.listeners.filter(l => l !== listener);
};
}
setSoundEnabled(enabled: boolean) {
this.soundEnabled = enabled;
}
}
export const errorManager = ErrorManager.getInstance();
@@ -277,7 +240,6 @@ export const errorManager = ErrorManager.getInstance();
// 增强的错误提示容器组件
export function ErrorNotificationContainer() {
const [notifications, setNotifications] = useState<Array<ErrorNotificationProps & { id: string }>>([]);
const [soundEnabled, setSoundEnabled] = useState(true);
useEffect(() => {
const unsubscribe = errorManager.subscribe((notification) => {
@@ -287,10 +249,6 @@ export function ErrorNotificationContainer() {
return unsubscribe;
}, []);
useEffect(() => {
errorManager.setSoundEnabled(soundEnabled);
}, [soundEnabled]);
const removeNotification = (id: string) => {
setNotifications(prev => prev.filter(n => n.id !== id));
};

View File

@@ -1,22 +1,19 @@
'use client';
import Link from 'next/link';
import { motion, AnimatePresence, useScroll, useTransform } from 'framer-motion';
import { useState, useCallback, useEffect } from 'react';
import { motion, AnimatePresence, useScroll } from 'framer-motion';
import { useState, useCallback, useMemo, useEffect } from 'react';
import {
HomeIcon,
ArrowLeftIcon,
ExclamationTriangleIcon,
XCircleIcon,
ClockIcon,
ServerIcon,
WifiIcon,
ClipboardDocumentIcon,
ArrowPathIcon,
CubeIcon,
QuestionMarkCircleIcon,
SparklesIcon,
RocketLaunchIcon
QuestionMarkCircleIcon
} from '@heroicons/react/24/outline';
import { messageManager } from './MessageNotification';
@@ -172,9 +169,7 @@ export function ErrorPage({
const [isRetrying, setIsRetrying] = useState(false);
const [showDetails, setShowDetails] = useState(false);
const [copySuccess, setCopySuccess] = useState(false);
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
const { scrollYProgress } = useScroll();
const opacity = useTransform(scrollYProgress, [0, 0.3], [1, 0.8]);
useScroll(); // keep hook for future use
const config = errorConfigs[type] || {};
const displayTitle = title || config.title || '出错了';
@@ -194,7 +189,8 @@ export function ErrorPage({
return JSON.stringify(details, null, 2);
}, [type, code, errorDetails]);
const defaultActions = {
const finalActions = useMemo(() => {
const defaults = {
primary: {
label: '返回主城',
href: '/'
@@ -208,18 +204,8 @@ export function ErrorPage({
}
}
};
const finalActions = { ...defaultActions, ...actions };
const getThemeStyles = () => {
return {
bg: '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',
card: 'bg-white/70 dark:bg-gray-800/70 backdrop-blur-lg',
text: 'text-gray-900 dark:text-white',
subtext: 'text-gray-600 dark:text-gray-300',
accent: 'text-orange-500 dark:text-orange-400'
};
};
return { ...defaults, ...actions };
}, [actions]);
const getIconColor = () => {
const colors = {
@@ -251,13 +237,13 @@ export function ErrorPage({
return 'from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600';
};
const handleRetry = async () => {
const handleRetry = useCallback(async () => {
if (onRetry) {
setIsRetrying(true);
try {
await onRetry();
messageManager.success('重试成功!', { duration: 3000 });
} catch (error) {
} catch {
messageManager.error('重试失败,请稍后重试', { duration: 5000 });
} finally {
setIsRetrying(false);
@@ -268,9 +254,9 @@ export function ErrorPage({
window.location.reload();
}
}
};
}, [onRetry]);
const handleCopyError = async () => {
const handleCopyError = useCallback(async () => {
try {
const details = generateErrorDetails();
if (typeof navigator !== 'undefined' && navigator.clipboard) {
@@ -290,15 +276,10 @@ export function ErrorPage({
messageManager.success('错误信息已复制到剪贴板', { duration: 2000 });
setTimeout(() => setCopySuccess(false), 2000);
}
} catch (error) {
} catch {
messageManager.error('复制失败,请手动复制', { duration: 3000 });
}
};
const handleReportError = () => {
messageManager.info('感谢您的反馈,我们会尽快处理', { duration: 3000 });
// 这里可以添加实际的错误报告逻辑
};
}, [generateErrorDetails]);
// 键盘快捷键支持
useEffect(() => {
@@ -315,8 +296,6 @@ export function ErrorPage({
}
}, [handleRetry]);
const themeStyles = getThemeStyles();
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 ${className}`}>
{/* Animated Background - 简化背景动画 */}

View File

@@ -6,14 +6,12 @@ import { ReactNode } from 'react';
interface LoadingSpinnerProps {
size?: 'sm' | 'md' | 'lg' | 'xl';
color?: 'orange' | 'blue' | 'green' | 'red' | 'purple' | 'gray';
speed?: 'slow' | 'normal' | 'fast';
className?: string;
}
export function LoadingSpinner({
size = 'md',
color = 'orange',
speed = 'normal',
className = ''
}: LoadingSpinnerProps) {
const sizes = {
@@ -32,12 +30,6 @@ export function LoadingSpinner({
gray: 'border-gray-500'
};
const speeds = {
slow: 'duration-2000',
normal: 'duration-1000',
fast: 'duration-500'
};
return (
<motion.div
className={`${sizes[size]} ${colors[color]} border-2 border-t-transparent rounded-full animate-spin ${className}`}

View File

@@ -7,8 +7,7 @@ import {
ExclamationTriangleIcon,
CheckCircleIcon,
InformationCircleIcon,
XCircleIcon,
BellIcon
XCircleIcon
} from '@heroicons/react/24/outline';
export type MessageType = 'success' | 'error' | 'warning' | 'info' | 'loading';

View File

@@ -5,17 +5,16 @@ import Link from 'next/link';
import { useRouter, usePathname } from 'next/navigation';
import { Bars3Icon, XMarkIcon, UserCircleIcon } from '@heroicons/react/24/outline';
import { motion, AnimatePresence, useScroll, useTransform, useSpring } from 'framer-motion';
import { Carrot } from 'lucide-react';
import { useAuth } from '@/contexts/AuthContext';
export default function Navbar() {
const [isOpen, setIsOpen] = useState(false);
const [isHidden, setIsHidden] = useState(false);
const [isScrolled, setIsScrolled] = useState(false);
const [showScrollTop, setShowScrollTop] = useState(false);
const [navbarHeight, setNavbarHeight] = useState(0);
const [scrollProgress, setScrollProgress] = useState(0);
const navbarRef = useRef<HTMLElement>(null);
const { user, isAuthenticated, logout } = useAuth();
const { user, isAuthenticated, isLoading: isAuthLoading, logout } = useAuth();
const router = useRouter();
const pathname = usePathname();
const { scrollY } = useScroll();
@@ -24,7 +23,6 @@ export default function Navbar() {
const springConfig = { stiffness: 300, damping: 30 };
const navbarY = useSpring(useTransform(scrollY, [0, 100], [0, -100]), springConfig);
const navbarOpacity = useSpring(useTransform(scrollY, [0, 50], [1, 0.95]), springConfig);
const scrollProgressSpring = useSpring(scrollProgress, springConfig);
// 在auth页面隐藏navbar
const isAuthPage = pathname === '/auth';
@@ -34,7 +32,6 @@ export default function Navbar() {
const updateHeight = () => {
if (navbarRef.current) {
const height = navbarRef.current.offsetHeight;
setNavbarHeight(height);
document.documentElement.style.setProperty('--navbar-height', `${height}px`);
}
};
@@ -69,9 +66,6 @@ export default function Navbar() {
// 检测是否滚动到顶部
setIsScrolled(currentScrollY > 20);
// 显示返回顶部按钮滚动超过300px
setShowScrollTop(currentScrollY > 300);
// 更敏感的隐藏逻辑:只要往下滚动就隐藏,不管滚动多少
if (!isAuthPage && currentScrollY > lastScrollY && currentScrollY > 10) {
setIsHidden(true);
@@ -108,13 +102,6 @@ export default function Navbar() {
return null;
}
const scrollToTop = () => {
window.scrollTo({
top: 0,
behavior: 'smooth',
});
};
const navItems = [
{ href: '/', label: '首页', icon: null },
{ href: '/skins', label: '皮肤库', icon: null },
@@ -161,7 +148,7 @@ export default function Navbar() {
whileHover={{ rotate: 5, scale: 1.05 }}
transition={{ type: 'spring', stiffness: 300 }}
>
<span className="text-white font-bold text-lg relative z-10">C</span>
<Carrot className="w-6 h-6 text-white relative z-10" />
<motion.div
className="absolute inset-0 bg-gradient-to-br from-white/20 to-transparent"
initial={{ x: '-100%', y: '-100%' }}
@@ -213,7 +200,12 @@ export default function Navbar() {
))}
{/* 用户头像框 - 增强的微交互 */}
{isAuthenticated ? (
{isAuthLoading ? (
<div className="flex items-center space-x-3">
<div className="w-9 h-9 rounded-full bg-gray-200 dark:bg-gray-700 animate-pulse" />
<div className="w-12 h-4 rounded bg-gray-200 dark:bg-gray-700 animate-pulse" />
</div>
) : isAuthenticated ? (
<div className="flex items-center space-x-4">
<motion.div
whileHover={{ scale: 1.05 }}
@@ -232,6 +224,7 @@ export default function Navbar() {
className="relative"
whileHover={{ scale: 1.1, rotate: 5 }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={user.avatar}
alt={user.username}
@@ -400,6 +393,7 @@ export default function Navbar() {
>
<div className="flex items-center space-x-3">
{user?.avatar ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={user.avatar}
alt={user.username}
@@ -463,8 +457,8 @@ export default function Navbar() {
</AnimatePresence>
</motion.nav>
{/* 返回顶部按钮 */}
<AnimatePresence>
{/* 返回顶部按钮 - 已注释掉,如需使用请恢复 showScrollTop 状态 */}
{/* <AnimatePresence>
{showScrollTop && (
<motion.button
initial={{ opacity: 0, scale: 0.8, y: 20 }}
@@ -472,7 +466,7 @@ export default function Navbar() {
exit={{ opacity: 0, scale: 0.8, y: 20 }}
whileHover={{ scale: 1.1, y: -2 }}
whileTap={{ scale: 0.9 }}
onClick={scrollToTop}
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
className="fixed bottom-8 right-8 z-40 bg-gradient-to-r from-orange-500 to-amber-500 text-white p-3 rounded-full shadow-lg hover:shadow-xl transition-all duration-200"
>
<motion.div
@@ -485,7 +479,7 @@ export default function Navbar() {
</motion.div>
</motion.button>
)}
</AnimatePresence>
</AnimatePresence> */}
</>
);
}

View File

@@ -1,9 +1,7 @@
'use client';
import { motion, AnimatePresence } from 'framer-motion';
import { usePathname, useSearchParams } from 'next/navigation';
import { useEffect, useState, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { motion } from 'framer-motion';
import { usePathname } from 'next/navigation';
interface PageTransitionProps {
children: React.ReactNode;
@@ -11,163 +9,15 @@ interface PageTransitionProps {
export default function PageTransition({ children }: PageTransitionProps) {
const pathname = usePathname();
const searchParams = useSearchParams();
const router = useRouter();
const [isNavigating, setIsNavigating] = useState(false);
const [displayChildren, setDisplayChildren] = useState(children);
const [pendingChildren, setPendingChildren] = useState<React.ReactNode | null>(null);
const navigationTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// 监听路由变化
useEffect(() => {
// 当 pathname 或 searchParams 变化时,表示路由发生了变化
if (children !== displayChildren) {
setPendingChildren(children);
setIsNavigating(true);
// 清除之前的超时
if (navigationTimeoutRef.current) {
clearTimeout(navigationTimeoutRef.current);
}
// 模拟加载时间,让 exit 动画有足够时间执行
navigationTimeoutRef.current = setTimeout(() => {
setDisplayChildren(children);
setPendingChildren(null);
setIsNavigating(false);
}, 500); // 给 exit 动画 300ms + 缓冲时间
}
}, [pathname, searchParams, children, displayChildren]);
// 清理超时
useEffect(() => {
return () => {
if (navigationTimeoutRef.current) {
clearTimeout(navigationTimeoutRef.current);
}
};
}, []);
const getPageVariants = (direction: 'left' | 'right' | 'up' | 'down' = 'right') => {
const directions = {
left: { x: -100, y: 0 },
right: { x: 100, y: 0 },
up: { x: 0, y: -100 },
down: { x: 0, y: 100 }
};
const exitDirections = {
left: { x: 100, y: 0 },
right: { x: -100, y: 0 },
up: { x: 0, y: 100 },
down: { x: 0, y: -100 }
};
return {
initial: {
opacity: 0,
...directions[direction],
scale: 0.9,
rotateX: -15
},
animate: {
opacity: 1,
x: 0,
y: 0,
scale: 1,
rotateX: 0,
transition: {
duration: 0.5,
type: "spring" as const,
stiffness: 100,
damping: 15
}
},
exit: {
opacity: 0,
...exitDirections[direction],
scale: 0.9,
rotateX: 15,
transition: {
duration: 0.3,
}
}
};
};
const getLoadingVariants = () => ({
initial: {
opacity: 0,
scale: 0.8,
y: 20
},
animate: {
opacity: 1,
scale: 1,
y: 0,
transition: {
duration: 0.3,
}
},
exit: {
opacity: 0,
scale: 0.8,
y: -20,
transition: {
duration: 0.2,
}
}
});
return (
<>
<AnimatePresence mode="wait">
{isNavigating && (
<motion.div
key="loading"
variants={getLoadingVariants()}
initial="initial"
animate="animate"
exit="exit"
className="fixed inset-0 z-50 flex items-center justify-center bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm pointer-events-none"
>
<div className="text-center">
<motion.div
animate={{
rotate: 360,
scale: [1, 1.1, 1]
}}
transition={{
rotate: { duration: 1, repeat: Infinity },
scale: { duration: 1.5, repeat: Infinity }
}}
className="w-12 h-12 border-4 border-orange-500 border-t-transparent rounded-full mx-auto mb-4"
/>
<motion.p
className="text-lg font-medium text-gray-700 dark:text-gray-300"
initial={{ opacity: 0, y: 10 }}
key={pathname}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
transition={{ duration: 0.3, ease: 'easeOut' }}
>
...
</motion.p>
</div>
{children}
</motion.div>
)}
</AnimatePresence>
<AnimatePresence mode="wait">
<motion.div
key={pathname + searchParams.toString()}
variants={getPageVariants()}
initial="initial"
animate="animate"
exit="exit"
className="min-h-screen"
>
{displayChildren}
</motion.div>
</AnimatePresence>
</>
);
}

View File

@@ -41,7 +41,7 @@ export default function ScrollToTop() {
exit={{ opacity: 0, scale: 0.8, y: 20 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
onClick={scrollToTop}
className="fixed bottom-6 right-6 w-12 h-12 bg-gradient-to-br from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white rounded-full shadow-lg hover:shadow-xl transition-all duration-200 flex items-center justify-center z-40 group"
className="fixed bottom-6 right-6 w-12 h-12 bg-gradient-to-br from-orange-400/70 to-amber-300/70 hover:from-orange-600/70 hover:to-orange-700/70 text-white rounded-full shadow-lg hover:shadow-xl transition-all duration-200 flex items-center justify-center z-40 group"
whileHover={{ scale: 1.1, y: -2 }}
whileTap={{ scale: 0.9 }}
>

View File

@@ -4,6 +4,7 @@ import { motion, AnimatePresence } from 'framer-motion';
import { useState } from 'react';
import { EyeIcon, ArrowDownTrayIcon, HeartIcon } from '@heroicons/react/24/outline';
import { HeartIcon as HeartIconSolid } from '@heroicons/react/24/solid';
import { Shirt } from 'lucide-react';
import SkinViewer from './SkinViewer';
import type { Texture } from '@/lib/api';
@@ -171,15 +172,15 @@ export default function SkinCard({
</AnimatePresence>
{texture.type === 'SKIN' ? (
<div className="relative w-full h-full bg-white dark:bg-gray-800">
<div className="relative w-full h-full flex items-center justify-center bg-white dark:bg-gray-800">
<SkinViewer
skinUrl={texture.url}
isSlim={texture.is_slim}
width={300}
height={300}
className={`w-full h-full transition-all duration-500 ${
imageLoaded ? 'opacity-100 scale-100' : 'opacity-0 scale-95'
} ${isHovered ? 'scale-110' : ''}`}
width={280}
height={280}
className={`transition-opacity duration-500 ${
imageLoaded ? 'opacity-100' : 'opacity-0'
}`}
autoRotate={isHovered}
walking={false}
onImageLoaded={() => setImageLoaded(true)}
@@ -197,7 +198,7 @@ export default function SkinCard({
transition={{ type: 'spring' as const, stiffness: 300 }}
animate={imageLoaded ? {} : { scale: [0.8, 1, 0.8] }}
>
<span className="text-2xl">🧥</span>
<Shirt className="w-10 h-10 text-orange-500" />
</motion.div>
<p className="text-sm text-gray-600 dark:text-gray-300 font-medium"></p>
</motion.div>
@@ -406,13 +407,13 @@ export default function SkinCard({
</motion.span>
</div>
<div className="text-xs text-gray-400">
{texture.uploader_id && (
{texture.uploader_username && (
<motion.span
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: index * 0.1 + 0.6 }}
>
by User #{texture.uploader_id}
by {texture.uploader_username}
</motion.span>
)}
</div>

View File

@@ -1,8 +1,8 @@
'use client';
import { useState, useEffect } from 'react';
import { motion, AnimatePresence, useSpring, useTransform } from 'framer-motion';
import { XMarkIcon, PlayIcon, PauseIcon, ArrowPathIcon, ForwardIcon } from '@heroicons/react/24/outline';
import { motion, AnimatePresence } from 'framer-motion';
import { XMarkIcon, PlayIcon } from '@heroicons/react/24/outline';
import SkinViewer from './SkinViewer';
interface SkinDetailModalProps {
@@ -18,37 +18,31 @@ interface SkinDetailModalProps {
favorite_count?: number;
download_count?: number;
created_at?: string;
uploader?: {
username: string;
};
uploader_username?: string;
} | null;
isExternalPreview?: boolean;
}
export default function SkinDetailModal({ isOpen, onClose, texture, isExternalPreview = false }: SkinDetailModalProps) {
const [isPlaying, setIsPlaying] = useState(false);
const [currentAnimation, setCurrentAnimation] = useState<'idle' | 'walking' | 'running' | 'swimming'>('idle');
const [autoRotate, setAutoRotate] = useState(!isExternalPreview);
const [rotation, setRotation] = useState(true);
const [isMinimized, setIsMinimized] = useState(false);
const [activeTab, setActiveTab] = useState<'preview' | 'info' | 'settings'>('preview');
const [isPaused, setIsPaused] = useState(false); // 新增:动画暂停状态
// 弹簧动画配置
const springConfig = { stiffness: 300, damping: 30 };
const scale = useSpring(1, springConfig);
const rotate = useSpring(0, springConfig);
// 重置状态当对话框关闭时
// 重置状态当对话框关闭时 - 使用事件循环避免同步 setState
useEffect(() => {
if (!isOpen) {
setIsPlaying(false);
const timer = setTimeout(() => {
setCurrentAnimation('idle');
setAutoRotate(true);
setAutoRotate(!isExternalPreview);
setRotation(true);
setIsMinimized(false);
setActiveTab('preview');
setIsPaused(false);
}, 0);
return () => clearTimeout(timer);
}
}, [isOpen]);
}, [isOpen, isExternalPreview]);
// 键盘事件处理
useEffect(() => {
@@ -61,7 +55,8 @@ export default function SkinDetailModal({ isOpen, onClose, texture, isExternalPr
break;
case ' ':
e.preventDefault();
setIsPlaying(!isPlaying);
// 空格键暂停/继续动画
setIsPaused(prev => !prev);
break;
case '1':
setCurrentAnimation('idle');
@@ -77,14 +72,14 @@ export default function SkinDetailModal({ isOpen, onClose, texture, isExternalPr
break;
case 'm':
case 'M':
setIsMinimized(!isMinimized);
setIsMinimized(prev => !prev);
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose, isPlaying, isMinimized]);
}, [isOpen, onClose]);
// 动画控制函数
const handleAnimationChange = (animation: 'idle' | 'walking' | 'running' | 'swimming') => {
@@ -135,37 +130,6 @@ export default function SkinDetailModal({ isOpen, onClose, texture, isExternalPr
}
});
const getAnimationButtonVariants = (isActive: boolean) => ({
initial: { scale: 0.9, opacity: 0.8 },
animate: {
scale: 1,
opacity: 1,
transition: {
duration: 0.2,
type: "spring" as const,
stiffness: 300,
damping: 20
}
},
hover: {
scale: 1.05,
transition: { duration: 0.2 }
},
tap: {
scale: 0.95,
transition: { duration: 0.1 }
},
active: {
scale: 1.02,
transition: {
duration: 0.2,
type: "spring" as const,
stiffness: 400,
damping: 25
}
}
});
if (!texture) return null;
return (
@@ -278,32 +242,23 @@ export default function SkinDetailModal({ isOpen, onClose, texture, isExternalPr
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.3, duration: 0.5 }}
>
<div className="w-full h-full max-w-2xl max-h-2xl">
<motion.div
animate={{
scale: isPlaying ? 1.02 : 1,
y: isPlaying ? -5 : 0
}}
transition={{
scale: { duration: 0.2 },
y: { duration: 0.2 }
}}
className="relative"
>
<div className="w-full h-full max-w-2xl max-h-2xl flex items-center justify-center">
<div className="relative">
<div className="absolute inset-0 bg-gradient-to-br from-orange-400/20 to-amber-400/20 dark:from-orange-500/10 dark:to-amber-500/10 rounded-2xl blur-xl"></div>
<SkinViewer
skinUrl={texture.url}
isSlim={texture.is_slim}
width={600}
height={600}
className="w-full h-full rounded-2xl shadow-2xl border-2 border-white/60 dark:border-gray-600/60 relative z-10"
autoRotate={autoRotate}
width={500}
height={500}
className="rounded-2xl shadow-2xl border-2 border-white/60 dark:border-gray-600/60 relative z-10"
autoRotate={autoRotate && currentAnimation === 'idle'}
walking={currentAnimation === 'walking'}
running={currentAnimation === 'running'}
jumping={currentAnimation === 'swimming'}
rotation={rotation}
paused={isPaused}
/>
</motion.div>
</div>
</div>
</motion.div>
@@ -332,25 +287,18 @@ export default function SkinDetailModal({ isOpen, onClose, texture, isExternalPr
{ key: 'walking', label: '步行', icon: null },
{ key: 'running', label: '跑步', icon: null },
{ key: 'swimming', label: '游泳', icon: null }
].map((anim, i) => (
<motion.button
].map((anim) => (
<button
key={anim.key}
variants={getAnimationButtonVariants(currentAnimation === anim.key)}
animate={currentAnimation === anim.key ? "active" : "animate"}
whileHover="hover"
whileTap="tap"
onClick={() => handleAnimationChange(anim.key as any)}
className={`p-4 rounded-xl text-sm font-semibold transition-all duration-300 transform ${
onClick={() => handleAnimationChange(anim.key as 'idle' | 'walking' | 'running' | 'swimming')}
className={`p-4 rounded-xl text-sm font-semibold transition-all duration-200 ${
currentAnimation === anim.key
? 'bg-gradient-to-r from-orange-500 via-amber-500 to-yellow-500 text-white shadow-2xl scale-105 ring-2 ring-orange-300 dark:ring-orange-500'
: 'bg-white/80 dark:bg-gray-700/80 text-gray-700 dark:text-gray-300 hover:bg-orange-50 dark:hover:bg-orange-900/30 border border-orange-200/50 dark:border-gray-600 hover:border-orange-300 dark:hover:border-orange-400 hover:scale-105 hover:shadow-lg'
? 'bg-gradient-to-r from-orange-500 via-amber-500 to-yellow-500 text-white shadow-lg ring-2 ring-orange-300 dark:ring-orange-500'
: 'bg-white/80 dark:bg-gray-700/80 text-gray-700 dark:text-gray-300 hover:bg-orange-50 dark:hover:bg-orange-900/30 border border-orange-200/50 dark:border-gray-600 hover:shadow-md'
}`}
initial={{ opacity: 0, y: 20 }}
transition={{ delay: 0.6 + i * 0.1 }}
>
{anim.label}
</motion.button>
</button>
))}
</div>
</motion.div>
@@ -380,7 +328,7 @@ export default function SkinDetailModal({ isOpen, onClose, texture, isExternalPr
)}
<div className="space-y-2 text-sm">
{texture.uploader && (
{texture.uploader_username && (
<motion.div
className="flex justify-between items-center"
initial={{ opacity: 0, x: -10 }}
@@ -388,7 +336,7 @@ export default function SkinDetailModal({ isOpen, onClose, texture, isExternalPr
transition={{ delay: 1.0 }}
>
<span className="text-gray-600 dark:text-gray-400">:</span>
<span className="font-medium text-gray-800 dark:text-gray-200">{texture.uploader.username}</span>
<span className="font-medium text-gray-800 dark:text-gray-200">{texture.uploader_username}</span>
</motion.div>
)}
@@ -440,7 +388,7 @@ export default function SkinDetailModal({ isOpen, onClose, texture, isExternalPr
</p>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex items-center"><kbd className="px-2 py-1 bg-white/70 dark:bg-gray-800/70 rounded border border-gray-300 dark:border-gray-600 text-xs mr-2"></kbd>/</div>
<div className="flex items-center"><kbd className="px-2 py-1 bg-white/70 dark:bg-gray-800/70 rounded border border-gray-300 dark:border-gray-600 text-xs mr-2"></kbd>/</div>
<div className="flex items-center"><kbd className="px-2 py-1 bg-white/70 dark:bg-gray-800/70 rounded border border-gray-300 dark:border-gray-600 text-xs mr-2">1-4</kbd></div>
<div className="flex items-center"><kbd className="px-2 py-1 bg-white/70 dark:bg-gray-800/70 rounded border border-gray-300 dark:border-gray-600 text-xs mr-2">M</kbd></div>
<div className="flex items-center"><kbd className="px-2 py-1 bg-white/70 dark:bg-gray-800/70 rounded border border-gray-300 dark:border-gray-600 text-xs mr-2">ESC</kbd></div>
@@ -469,6 +417,7 @@ export default function SkinDetailModal({ isOpen, onClose, texture, isExternalPr
walking={currentAnimation === 'walking'}
running={currentAnimation === 'running'}
jumping={currentAnimation === 'swimming'}
paused={isPaused}
/>
</div>
<p className="text-sm font-medium text-gray-700 dark:text-gray-300 truncate">

View File

@@ -1,6 +1,7 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { AlertTriangle } from 'lucide-react';
import { SkinViewer as SkinViewer3D, WalkingAnimation, RunningAnimation, FlyingAnimation, IdleAnimation } from 'skinview3d';
interface SkinViewerProps {
@@ -17,6 +18,7 @@ interface SkinViewerProps {
rotation?: boolean; // 新增:旋转控制
isExternalPreview?: boolean; // 新增:是否为外部预览
onImageLoaded?: () => void; // 新增:图片加载完成回调
paused?: boolean; // 新增:暂停动画
}
export default function SkinViewer({
@@ -33,6 +35,7 @@ export default function SkinViewer({
rotation = true,
isExternalPreview = false, // 新增默认为false
onImageLoaded, // 新增:图片加载完成回调
paused = false, // 新增:默认不暂停
}: SkinViewerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const viewerRef = useRef<SkinViewer3D | null>(null);
@@ -42,69 +45,79 @@ export default function SkinViewer({
// 预加载皮肤图片以检查是否可访问
useEffect(() => {
if (!skinUrl) return;
if (!skinUrl) {
// 使用 requestAnimationFrame 避免同步 setState 触发级联渲染
const frame = requestAnimationFrame(() => {
setIsLoading(false);
setHasError(true);
});
return () => cancelAnimationFrame(frame);
}
// 使用 requestAnimationFrame 避免同步 setState 触发级联渲染
const initFrame = requestAnimationFrame(() => {
setIsLoading(true);
setHasError(false);
setImageLoaded(false);
});
let isCancelled = false;
const img = new Image();
img.crossOrigin = 'anonymous'; // 尝试跨域访问
img.crossOrigin = 'anonymous';
img.onload = () => {
if (isCancelled) return;
console.log('皮肤图片加载成功:', skinUrl);
setImageLoaded(true);
setIsLoading(false);
if (onImageLoaded) {
onImageLoaded();
}
onImageLoaded?.();
};
img.onerror = (error) => {
console.error('皮肤图片加载失败:', skinUrl, error);
img.onerror = () => {
if (isCancelled) return;
console.error('皮肤图片加载失败:', skinUrl);
setHasError(true);
setIsLoading(false);
};
// 开始加载图片
img.src = skinUrl;
return () => {
// 清理
cancelAnimationFrame(initFrame);
isCancelled = true;
img.onload = null;
img.onerror = null;
};
}, [skinUrl]);
}, [skinUrl, onImageLoaded]);
// 初始化3D查看器
useEffect(() => {
if (!canvasRef.current || !imageLoaded || hasError) return;
const canvas = canvasRef.current;
let viewer: SkinViewer3D | null = null;
// 使用 requestAnimationFrame 避免在 effect 体中同步 setState
const initFrame = requestAnimationFrame(() => {
try {
console.log('初始化3D皮肤查看器:', { skinUrl, isSlim, width, height });
// 使用canvas的实际尺寸参考blessingskin
const canvas = canvasRef.current;
const viewer = new SkinViewer3D({
viewer = new SkinViewer3D({
canvas: canvas,
width: canvas.clientWidth || width,
height: canvas.clientHeight || height,
width: width,
height: height,
skin: skinUrl,
cape: capeUrl,
model: isSlim ? 'slim' : 'default',
zoom: 1.0, // 使用blessingskin的zoom方式
zoom: 1.0,
});
viewerRef.current = viewer;
// 设置背景和控制选项 - 参考blessingskin
// 设置背景和控制选项
viewer.background = null; // 透明背景
viewer.autoRotate = false; // 完全禁用自动旋转
// 调整光照设置,避免皮肤发黑
viewer.globalLight.intensity = 0.8; // 增加环境光强度
viewer.cameraLight.intensity = 0.4; // 降低相机光源强度,避免过强的阴影
// 外部预览时禁用所有动画和旋转
if (isExternalPreview) {
viewer.autoRotate = false;
@@ -119,14 +132,15 @@ export default function SkinViewer({
viewer.controls.enablePan = false; // 禁用平移
console.log('3D皮肤查看器初始化成功');
} catch (error) {
console.error('3D皮肤查看器初始化失败:', error);
setHasError(true);
}
});
// 清理函数
return () => {
cancelAnimationFrame(initFrame);
if (viewerRef.current) {
try {
viewerRef.current.dispose();
@@ -145,6 +159,22 @@ export default function SkinViewer({
const viewer = viewerRef.current;
// 暂停动画时,使用 animation.paused 属性来暂停动画
if (paused) {
if (viewer.animation) {
viewer.animation.paused = true;
}
viewer.autoRotate = false;
console.log('动画已暂停');
return;
} else {
// 恢复动画
if (viewer.animation) {
viewer.animation.paused = false;
console.log('动画已恢复');
}
}
// 外部预览时只使用静止动画,禁用所有其他动画
if (isExternalPreview) {
viewer.animation = new IdleAnimation();
@@ -174,7 +204,7 @@ export default function SkinViewer({
viewer.autoRotate = autoRotate && !walking && !running && !jumping;
}
}, [walking, running, jumping, autoRotate, isExternalPreview]);
}, [walking, running, jumping, autoRotate, isExternalPreview, paused]);
// 当皮肤URL改变时更新
useEffect(() => {
@@ -234,7 +264,9 @@ export default function SkinViewer({
style={{ width: width, height: height }}
>
<div className="text-center p-4">
<div className="text-4xl mb-2"></div>
<div className="flex justify-center mb-2">
<AlertTriangle className="w-10 h-10 text-red-500" />
</div>
<div className="text-sm text-red-600 dark:text-red-400 font-medium mb-1">
</div>
@@ -268,11 +300,8 @@ export default function SkinViewer({
ref={canvasRef}
className={className}
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
height: '100%'
width: width,
height: height
}}
/>
);

View File

@@ -0,0 +1,488 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { Shield, X, Check } from 'lucide-react';
import axios from 'axios';
import { API_BASE_URL } from '@/lib/api';
/**
* 滑块验证码组件属性接口定义
* @interface SliderCaptchaProps
* @property {function} onVerify - 验证结果回调函数参数为验证是否成功及验证码ID成功时
* @property {function} onClose - 关闭验证码组件的回调函数
*/
interface SliderCaptchaProps {
onVerify: (success: boolean, captchaId?: string) => void;
onClose: () => void;
}
// 轨道宽度(与背景图宽度一致)
const TRACK_WIDTH = 300;
// 滑块按钮宽度
const SLIDER_WIDTH = 50;
// 背景图宽度(与后端返回的背景图尺寸匹配)
// const CANVAS_WIDTH = 300;
/**
* 滑块验证码组件
* 功能:通过拖拽滑块完成拼图验证,与后端交互获取验证码资源和验证结果
* 特点:
* - 支持鼠标和触摸事件适配PC和移动端
* - 与后端接口交互,获取背景图、拼图和验证结果
* - 包含验证状态反馈和错误处理
* @param {SliderCaptchaProps} props - 组件属性
* @returns {JSX.Element} 滑块验证码组件JSX元素
*/
export const SliderCaptcha: React.FC<SliderCaptchaProps> = ({ onVerify, onClose }) => {
// 拖拽状态:是否正在拖拽滑块
const [isDragging, setIsDragging] = useState(false);
// 滑块当前位置x坐标
const [sliderPosition, setSliderPosition] = useState(0);
// 拼图y坐标从后端获取
const [puzzleY, setPuzzleY] = useState(0);
// 验证状态:是否验证成功
const [isVerified, setIsVerified] = useState(false);
// 加载状态:是否正在加载资源或验证中
const [isLoading, setIsLoading] = useState(false);
// 尝试次数:记录验证失败的次数
const [attempts, setAttempts] = useState(0);
// 错误显示状态:是否显示验证错误提示
const [showError, setShowError] = useState(false);
// 拖拽偏移量:鼠标/触摸点与滑块中心的偏移,用于精准计算滑块位置
const [dragOffset, setDragOffset] = useState(0);
// 背景图Base64字符串从后端获取
const [backgroundImage, setBackgroundImage] = useState<string>('');
// 拼图Base64字符串从后端获取
const [puzzleImage, setPuzzleImage] = useState<string>('');
// 验证码进程ID从后端获取用于验证时标识当前验证码
const [processId, setProcessId] = useState<string>('');
// 验证结果false-未验证/验证失败true-验证成功,'error'-请求错误
const [verifyResult, setVerifyResult] = useState<boolean | string>(false);
// 提示信息:显示后端返回的提示或默认提示
const [msg, setMsg] = useState<string>('拖动滑块完成拼图');
const sliderRef = useRef<HTMLDivElement | null>(null);
const trackRef = useRef<HTMLDivElement | null>(null);
/**
* 获取验证码资源(背景图、拼图、位置信息等)
* 从后端接口请求验证码所需的资源数据包括背景图、拼图的Base64编码
* 拼图的y坐标和进程ID并初始化拼图的x坐标
*/
const fetchCaptchaResources = useCallback(async () => {
try {
// 开始加载,设置加载状态
setIsLoading(true);
// 请求验证码资源接口
const response = await axios.get(`${API_BASE_URL}/captcha/generate`, {
withCredentials: true // 关键:允许跨域携带凭证
});
const { code, message: resMsg, data } = response.data;
const { masterImage, tileImage, captchaId, y } = data;
// 后端返回成功状态code=200
if (code === 200) {
// 设置背景图
setBackgroundImage(masterImage);
// 设置拼图图片
setPuzzleImage(tileImage);
// 设置拼图y坐标从后端获取以背景图左上角为原点
setPuzzleY(y);
// 设置进程ID用于后续验证
setProcessId(captchaId);
// 随机生成拼图x坐标确保拼图在背景图内
// setPuzzlePosition(Math.random() * (CANVAS_WIDTH - 50 - 50) + 50);
// 保存后端返回的提示信息
setMsg(resMsg);
// 结束加载状态
setIsLoading(false);
return;
}
// 后端返回失败状态非200
setMsg(resMsg || '生成验证码失败');
setVerifyResult('error');
setIsLoading(false);
} catch (error) {
// 捕获请求异常
const errMsg = '获取验证码资源失败: ' + (error as Error).message;
console.error(errMsg);
setMsg(errMsg);
setVerifyResult('error');
setIsLoading(false);
}
}, []);
/**
* 组件挂载时自动获取验证码资源
* 依赖fetchCaptchaResources函数确保函数变化时重新执行
*/
useEffect(() => {
fetchCaptchaResources();
}, [fetchCaptchaResources]);
/**
* 开始拖拽处理函数
* 记录初始拖拽位置和偏移量,设置拖拽状态
* @param {number} clientX - 鼠标/触摸点的x坐标
*/
const handleStart = useCallback((clientX: number) => {
if (isVerified || isLoading || verifyResult === 'error') return;
setIsDragging(true);
setShowError(false);
const slider = sliderRef.current;
if (slider) {
const rect = slider.getBoundingClientRect();
setDragOffset(clientX - rect.left - SLIDER_WIDTH / 2);
}
}, [isVerified, isLoading, verifyResult]);
/**
* 拖拽移动处理函数
* 根据鼠标/触摸点的移动更新滑块位置,限制滑块在轨道范围内
* @param {number} clientX - 鼠标/触摸点的x坐标
*/
const handleMove = useCallback((clientX: number) => {
if (!isDragging || isVerified || isLoading || verifyResult === 'error') return;
const track = trackRef.current;
if (!track) return;
const rect = track.getBoundingClientRect();
const x = clientX - rect.left - dragOffset;
const maxPosition = TRACK_WIDTH - SLIDER_WIDTH;
const newPosition = Math.max(0, Math.min(x, maxPosition));
setSliderPosition(newPosition);
}, [isDragging, isVerified, isLoading, dragOffset, verifyResult]);
/**
* 结束拖拽处理函数
* 拖拽结束后向后端发送验证请求,处理验证结果
*/
const handleEnd = useCallback(async () => {
if (!isDragging || isVerified || isLoading || verifyResult === 'error') return;
setIsDragging(false);
setIsLoading(true);
try {
// 向后端发送验证请求参数为滑块位置x坐标和进程ID
// 使用sliderPosition作为dx值这是拼图块左上角的位置
const response = await axios.post(`${API_BASE_URL}/captcha/verify`, {
dx: sliderPosition, // 滑块位置拼图左上角x坐标以背景图左上角为原点
captchaId: processId // 验证码进程ID
},{ withCredentials: true });
const { code, message: resMsg } = response.data;
// 保存后端返回的提示信息
setMsg(resMsg);
// 根据后端返回的code判断验证结果
// 验证成功code=200
if (code === 200) {
// 增加尝试次数
setAttempts(prev => prev + 1);
// 重置所有状态,确保验证成功状态的纯净性
setShowError(false);
setVerifyResult(false);
// 直接设置验证成功状态,不使用异步更新
setIsVerified(true);
// 延迟1.2秒后调用验证成功回调,透传后端返回的 captchaId 供注册接口使用
setTimeout(() => onVerify(true, processId), 1200);
}
// 验证失败code=400
else if (code === 400) {
// 确保错误状态的正确性:验证失败显示红色
setVerifyResult(false);
setShowError(true);
setIsVerified(false);
// 增加尝试次数
setAttempts(prev => prev + 1);
// 1.5秒后重置滑块位置、隐藏错误提示并重置验证结果
setTimeout(() => {
setSliderPosition(0);
setShowError(false);
setVerifyResult(false);
setIsVerified(false);
}, 1500);
}
// 后端返回系统错误500
else if (code === 500) {
// 系统错误显示橙色
setVerifyResult('error');
setShowError(true);
setIsVerified(false);
// 增加尝试次数
setAttempts(prev => prev + 1);
// 1.5秒后重置滑块位置、隐藏错误提示并重置验证结果
setTimeout(() => {
setSliderPosition(0);
setShowError(false);
setVerifyResult(false);
setIsVerified(false);
}, 1500);
}
} catch (error) {
// 捕获验证请求异常
const errMsg = '验证请求失败: ' + (error as Error).message;
console.error(errMsg);
setMsg(errMsg);
setVerifyResult('error');
setShowError(true);
// 1.5秒后重置滑块位置并隐藏错误提示
setTimeout(() => {
setSliderPosition(0);
setShowError(false);
}, 1500);
} finally {
// 无论成功失败,都结束加载状态
setIsLoading(false);
}
}, [isDragging, isVerified, isLoading, sliderPosition, processId, onVerify, verifyResult]);
/**
* 鼠标按下事件处理
* 阻止默认行为,调用开始拖拽函数
* @param {React.MouseEvent} e - 鼠标事件对象
*/
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
handleStart(e.clientX);
};
/**
* 鼠标移动事件处理
* 阻止默认行为,调用拖拽移动函数
* @param {MouseEvent} e - 鼠标事件对象
*/
const handleMouseMove = useCallback((e: MouseEvent) => {
e.preventDefault();
handleMove(e.clientX);
}, [handleMove]);
/**
* 鼠标释放事件处理
* 阻止默认行为,调用结束拖拽函数
* @param {MouseEvent} e - 鼠标事件对象
*/
const handleMouseUp = useCallback((e: MouseEvent) => {
e.preventDefault();
handleEnd();
}, [handleEnd]);
/**
* 触摸开始事件处理
* 阻止默认行为,调用开始拖拽函数(适配移动端)
* @param {React.TouchEvent} e - 触摸事件对象
*/
const handleTouchStart = (e: React.TouchEvent) => {
e.preventDefault();
handleStart(e.touches[0].clientX);
};
/**
* 触摸移动事件处理
* 阻止默认行为,调用拖拽移动函数(适配移动端)
* @param {TouchEvent} e - 触摸事件对象
*/
const handleTouchMove = useCallback((e: TouchEvent) => {
e.preventDefault();
handleMove(e.touches[0].clientX);
}, [handleMove]);
/**
* 触摸结束事件处理
* 阻止默认行为,调用结束拖拽函数(适配移动端)
* @param {TouchEvent} e - 触摸事件对象
*/
const handleTouchEnd = useCallback((e: TouchEvent) => {
e.preventDefault();
handleEnd();
}, [handleEnd]);
/**
* 拖拽状态变化时绑定/解绑全局事件
* 当开始拖拽时为document绑定鼠标和触摸移动/结束事件;
* 当结束拖拽时,移除这些事件监听
*/
useEffect(() => {
if (isDragging) {
// 绑定鼠标事件
document.addEventListener('mousemove', handleMouseMove, { passive: false });
document.addEventListener('mouseup', handleMouseUp, { passive: false });
// 绑定触摸事件
document.addEventListener('touchmove', handleTouchMove, { passive: false });
document.addEventListener('touchend', handleTouchEnd, { passive: false });
// 组件卸载或拖拽状态结束时,移除事件监听
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
};
}
}, [isDragging, handleMouseMove, handleMouseUp, handleTouchMove, handleTouchEnd]);
/**
* 获取滑块显示的图标
* 根据不同状态(加载中、已验证、错误、默认)返回不同图标
* @returns {JSX.Element} 滑块图标
*/
const getSliderIcon = () => {
if (isLoading) {
// 加载中显示旋转动画
return <div className="w-5 h-5 border-2 border-blue-300 border-t-blue-600 rounded-full animate-spin" />;
}
// 验证成功时,无论其他状态如何,都显示对勾图标
if (isVerified) {
return <Check className="w-5 h-5 text-green-600" />;
}
// 验证失败或错误时显示叉号图标
if (showError || verifyResult === 'error') {
return <X className="w-5 h-5 text-red-600" />;
}
// 默认显示蓝色圆点
return <div className="w-3 h-3 bg-blue-500 rounded-full" />;
};
const getStatusText = () => {
if (isVerified) {
// 验证成功时优先显示成功消息
return msg;
}
if (verifyResult === 'error' || showError) {
// 错误或验证失败时显示后端返回的消息
return msg;
}
// 默认显示拖拽提示
return '拖动滑块完成拼图';
};
const getStatusColor = () => {
if (isVerified) return 'text-green-700';
if (verifyResult === 'error') return 'text-orange-700';
if (showError) return 'text-red-700';
return 'text-gray-600';
};
const getProgressColor = () => {
// 验证成功时,无论其他状态如何,都显示绿色渐变
if (isVerified) return 'bg-gradient-to-r from-green-400 to-green-500';
// 系统错误后端返回400/500显示橙色渐变
if (verifyResult === 'error') return 'bg-gradient-to-r from-orange-400 to-orange-500';
// 验证失败后端返回200但data=false显示红色渐变
if (showError && verifyResult !== 'error') return 'bg-gradient-to-r from-red-400 to-red-500';
// 默认显示蓝色渐变
return 'bg-gradient-to-r from-blue-400 to-blue-500';
};
return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-md mx-auto transform transition-all duration-300 scale-100">
{/* 头部区域:显示标题和关闭按钮 */}
<div className="flex items-center justify-between p-6 border-b border-gray-100">
<div className="flex items-center space-x-3">
<div className="w-8 h-8 bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg flex items-center justify-center">
<Shield className="w-4 h-4 text-white" />
</div>
<h3 className="text-lg font-semibold text-gray-900"></h3>
</div>
{/* 关闭按钮 */}
<button onClick={onClose} className="p-2 rounded-lg hover:bg-gray-100 transition-colors" title="关闭">
<X className="w-4 h-4 text-gray-500" />
</button>
</div>
{/* 显示验证码图片和滑块 */}
<div className="p-6">
<div className="relative">
{/* 背景图片容器尺寸300x200px与后端图片尺寸匹配 */}
<div className="relative bg-gray-200 rounded-lg w-[300px] h-[200px] mb-4 overflow-hidden mx-auto">
{backgroundImage && (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={backgroundImage}
alt="验证背景"
className="h-full w-full object-cover"
/>
)}
{/* 可移动拼图块 */}
{puzzleImage && (
<div
className={`absolute ${isDragging ? '' : 'transition-all duration-300'}`}
style={{
left: `${sliderPosition}px`,
top: `${puzzleY}px`,
zIndex: 10,
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={puzzleImage}
alt="拼图块"
className={`${isVerified ? 'opacity-100' : 'opacity-90'}`}
style={{
filter: isVerified ? 'drop-shadow(0 0 10px rgba(34, 197, 94, 0.5))' : 'drop-shadow(0 2px 4px rgba(0,0,0,0.3))'
}}
/>
</div>
)}
</div>
{/* 提示文本 */}
<p className="text-sm text-gray-600 mb-4 text-center">{getStatusText()}</p>
</div>
{/* 滑动轨道 */}
<div className="relative bg-gray-100 rounded-full h-12 overflow-hidden select-none" ref={trackRef} style={{ width: `${TRACK_WIDTH}px`, margin: '0 auto' }}>
{/* 进度条 */}
<div
className={`absolute left-0 top-0 h-full ${isDragging ? '' : 'transition-all duration-200 ease-out'} ${getProgressColor()}`}
style={{
width: `${sliderPosition + SLIDER_WIDTH}px`,
transform: isDragging ? 'scaleY(1.05)' : 'scaleY(1)',
transformOrigin: 'bottom'
}}
/>
{/* 滑块按钮 */}
<div
className={`absolute top-1 w-10 h-10 bg-white rounded-full shadow-lg cursor-pointer flex items-center justify-center ${isDragging ? '' : 'transition-all duration-200 ease-out'} select-none ${
isDragging ? 'scale-110 shadow-xl' : 'scale-100'
} ${isVerified || verifyResult === 'error' ? 'cursor-default' : 'cursor-grab active:cursor-grabbing'}`}
style={{ left: `${sliderPosition + 2}px`, zIndex: 10 }}
onMouseDown={verifyResult === 'error' ? undefined : handleMouseDown}
onTouchStart={verifyResult === 'error' ? undefined : handleTouchStart}
ref={sliderRef}
>
{getSliderIcon()}
</div>
{/* 轨道上的提示文字 */}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<span className={`text-sm font-medium transition-all duration-300 ${
sliderPosition > 60 ? 'opacity-0 transform translate-x-4' : 'opacity-100 transform translate-x-0'
} ${getStatusColor()}`}>
{getStatusText()}
</span>
</div>
</div>
</div>
{/* 底部信息区域 */}
<div className="px-6 pb-6">
<div className="flex items-center justify-between text-xs text-gray-500">
<span>: {attempts}</span>
<span className="flex items-center space-x-1">
<Shield className="w-3 h-3" />
<span></span>
</span>
</div>
</div>
</div>
</div>
);
};
export default SliderCaptcha;

View File

@@ -1,7 +1,7 @@
'use client';
import { motion } from 'framer-motion';
import { UserIcon, PencilIcon, TrashIcon, CheckIcon } from '@heroicons/react/24/outline';
import { UserIcon, PencilIcon, TrashIcon } from '@heroicons/react/24/outline';
import SkinViewer from '@/components/SkinViewer';
import type { Profile } from '@/lib/api';
@@ -15,7 +15,6 @@ interface CharacterCardProps {
onSave: (uuid: string) => void;
onCancel: () => void;
onDelete: (uuid: string) => void;
onSetActive: (uuid: string) => void;
onSelectSkin: (uuid: string) => void;
onEditNameChange: (name: string) => void;
}
@@ -30,7 +29,6 @@ export default function CharacterCard({
onSave,
onCancel,
onDelete,
onSetActive,
onSelectSkin,
onEditNameChange
}: CharacterCardProps) {
@@ -42,35 +40,41 @@ export default function CharacterCard({
transition={{ duration: 0.2 }}
>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2 flex-1">
{isEditing ? (
<input
type="text"
value={editName}
onChange={(e) => onEditNameChange(e.target.value)}
className="text-lg font-semibold bg-transparent border-b border-orange-500 focus:outline-none text-gray-900 dark:text-white flex-1 mr-2"
className="text-lg font-semibold bg-transparent border-b border-orange-500 focus:outline-none text-gray-900 dark:text-white flex-1"
onBlur={() => onSave(profile.uuid)}
onKeyPress={(e) => e.key === 'Enter' && onSave(profile.uuid)}
autoFocus
/>
) : (
<h3 className="text-lg font-semibold text-gray-900 dark:text-white truncate flex-1">{profile.name}</h3>
)}
{profile.is_active && (
<span className="px-2 py-1 bg-gradient-to-r from-green-500 to-emerald-500 text-white text-xs rounded-full flex items-center space-x-1">
<CheckIcon className="w-3 h-3" />
<span>使</span>
</span>
<>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white truncate">{profile.name}</h3>
<motion.button
onClick={() => onEdit(profile.uuid, profile.name)}
className="text-gray-500 hover:text-orange-500 transition-colors"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
title="改名"
>
<PencilIcon className="w-4 h-4" />
</motion.button>
</>
)}
</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 relative overflow-hidden">
{skinUrl ? (
<SkinViewer
skinUrl={skinUrl}
isSlim={isSlim}
width={200}
height={200}
className="w-full h-full"
width={120}
height={120}
autoRotate={false}
/>
) : (
@@ -82,32 +86,10 @@ export default function CharacterCard({
<UserIcon className="w-10 h-10 text-white" />
</motion.div>
)}
{/* 皮肤选择按钮 */}
<motion.button
onClick={() => onSelectSkin(profile.uuid)}
className="absolute bottom-2 right-2 bg-gradient-to-r from-orange-500 to-amber-500 text-white p-2 rounded-full shadow-lg"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
title="选择皮肤"
>
<PencilIcon className="w-4 h-4" />
</motion.button>
</div>
{/* 操作按钮 */}
<div className="flex gap-2">
{!profile.is_active && (
<motion.button
onClick={() => onSetActive(profile.uuid)}
className="flex-1 bg-gradient-to-r from-green-500 to-emerald-500 hover:from-green-600 hover:to-emerald-600 text-white text-sm py-2 px-3 rounded-lg transition-all duration-200"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
使
</motion.button>
)}
{isEditing ? (
<>
<motion.button
@@ -130,13 +112,12 @@ export default function CharacterCard({
) : (
<>
<motion.button
onClick={() => onEdit(profile.uuid, profile.name)}
className="flex-1 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 text-sm py-2 px-3 rounded-lg transition-all duration-200"
onClick={() => onSelectSkin(profile.uuid)}
className="flex-1 bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 text-white text-sm py-2 px-3 rounded-lg transition-all duration-200"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<PencilIcon className="w-4 h-4 inline mr-1" />
</motion.button>
<motion.button
onClick={() => onDelete(profile.uuid)}

View File

@@ -2,8 +2,7 @@
import { useState } from 'react';
import { motion } from 'framer-motion';
import { PhotoIcon, ArrowDownTrayIcon, EyeIcon, HeartIcon } from '@heroicons/react/24/outline';
import { HeartIcon as HeartIconSolid } from '@heroicons/react/24/solid';
import { HeartIcon } from '@heroicons/react/24/outline';
import SkinCard from '@/components/SkinCard';
import SkinDetailModal from '@/components/SkinDetailModal';
import type { Texture } from '@/lib/api';

View File

@@ -1,13 +1,11 @@
'use client';
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { motion } from 'framer-motion';
import {
PhotoIcon,
ArrowDownTrayIcon,
EyeIcon,
TrashIcon,
CloudArrowUpIcon
CloudArrowUpIcon,
TrashIcon
} from '@heroicons/react/24/outline';
import SkinCard from '@/components/SkinCard';
import SkinDetailModal from '@/components/SkinDetailModal';
@@ -68,7 +66,6 @@ export default function MySkinsTab({
key={skin.id}
texture={skin}
onViewDetails={handleViewDetails}
onToggleVisibility={onToggleVisibility}
customActions={
<div className="flex gap-2">
<button

View File

@@ -1,6 +1,7 @@
'use client';
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { API_BASE_URL } from '@/lib/api';
interface User {
id: number;
@@ -22,14 +23,12 @@ interface AuthContextType {
isLoading: boolean;
isAuthenticated: boolean;
login: (username: string, password: string) => Promise<void>;
register: (username: string, email: string, password: string, verificationCode: string) => Promise<void>;
register: (username: string, email: string, password: string, verificationCode: string, captchaId?: string) => Promise<void>;
logout: () => void;
updateUser: (userData: Partial<User>) => void;
refreshUser: () => Promise<void>;
}
const API_BASE_URL = 'http://localhost:8080/api/v1';
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
@@ -48,6 +47,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
};
checkAuthStatus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const setAuthToken = (token: string) => {
@@ -106,7 +106,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
};
const register = async (username: string, email: string, password: string, verificationCode: string) => {
const register = async (username: string, email: string, password: string, verificationCode: string, captchaId?: string) => {
setIsLoading(true);
try {
const response = await fetch(`${API_BASE_URL}/auth/register`, {
@@ -119,6 +119,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
email,
password,
verification_code: verificationCode,
...(captchaId && { captcha_id: captchaId }),
}),
});

View File

@@ -1,8 +1,9 @@
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8080/api/v1';
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || '/api/v1';
export interface Texture {
id: number;
uploader_id: number;
uploader_username?: string;
name: string;
description?: string;
type: 'SKIN' | 'CAPE';
@@ -24,7 +25,6 @@ export interface Profile {
name: string;
skin_id?: number;
cape_id?: number;
is_active: boolean;
last_used_at?: string;
created_at: string;
updated_at: string;
@@ -38,6 +38,32 @@ export interface PaginatedResponse<T> {
total_pages: number;
}
// 后端原始分页响应结构(统一返回 { list, total, page, per_page },不含 total_pages/page_size
type RawPaginatedData<T> = {
list?: T[];
total?: number;
page?: number;
per_page?: number;
page_size?: number;
};
// 在前端补齐 page_size 与 total_pages由 total / page_size 向上取整),以便 UI 分页使用
function normalizePaginatedData<T>(raw: RawPaginatedData<T>): PaginatedResponse<T> {
const list = raw.list ?? [];
const total = raw.total ?? 0;
const page = raw.page ?? 1;
const page_size = raw.per_page ?? raw.page_size ?? 20;
const total_pages = page_size > 0 ? Math.max(1, Math.ceil(total / page_size)) : 1;
return { list, total, page, page_size, total_pages };
}
// 后端错误响应的 data 可能不是分页结构code !== 200用此类型宽松接收
type RawApiResponse<T> = {
code: number;
message: string;
data: T;
};
export interface ApiResponse<T> {
code: number;
message: string;
@@ -75,7 +101,11 @@ export async function searchTextures(params: {
},
});
return response.json();
const result: RawApiResponse<RawPaginatedData<Texture>> = await response.json();
if (result.code === 200 && result.data) {
return { ...result, data: normalizePaginatedData(result.data) };
}
return result as ApiResponse<PaginatedResponse<Texture>>;
}
// 获取材质详情
@@ -114,7 +144,11 @@ export async function getMyTextures(params: {
headers: getAuthHeaders(),
});
return response.json();
const result: RawApiResponse<RawPaginatedData<Texture>> = await response.json();
if (result.code === 200 && result.data) {
return { ...result, data: normalizePaginatedData(result.data) };
}
return result as ApiResponse<PaginatedResponse<Texture>>;
}
// 获取用户收藏的材质列表
@@ -131,7 +165,11 @@ export async function getFavoriteTextures(params: {
headers: getAuthHeaders(),
});
return response.json();
const result: RawApiResponse<RawPaginatedData<Texture>> = await response.json();
if (result.code === 200 && result.data) {
return { ...result, data: normalizePaginatedData(result.data) };
}
return result as ApiResponse<PaginatedResponse<Texture>>;
}
// 获取用户档案列表
@@ -156,10 +194,11 @@ export async function createProfile(name: string): Promise<ApiResponse<Profile>>
}
// 更新档案
// skin_id / cape_id: 显式传 null 表示移除当前皮肤的关联;传 number 表示设置;不传表示不修改。
export async function updateProfile(uuid: string, data: {
name?: string;
skin_id?: number;
cape_id?: number;
skin_id?: number | null;
cape_id?: number | null;
}): Promise<ApiResponse<Profile>> {
const response = await fetch(`${API_BASE_URL}/profile/${uuid}`, {
method: 'PUT',
@@ -180,16 +219,6 @@ export async function deleteProfile(uuid: string): Promise<ApiResponse<null>> {
return response.json();
}
// 设置活跃档案
export async function setActiveProfile(uuid: string): Promise<ApiResponse<{ message: string }>> {
const response = await fetch(`${API_BASE_URL}/profile/${uuid}/activate`, {
method: 'POST',
headers: getAuthHeaders(),
});
return response.json();
}
// 获取用户信息
export async function getUserProfile(): Promise<ApiResponse<{
id: number;
@@ -264,23 +293,39 @@ export async function uploadTexture(file: File, data: {
return response.json();
}
// 生成头像上传URL
export async function generateAvatarUploadUrl(fileName: string): Promise<ApiResponse<{
post_url: string;
form_data: Record<string, string>;
// 直接上传头像文件到后端multipart/form-data
// 后端接口 POST /user/avatar/upload,由后端负责写入对象存储并更新用户头像
export async function uploadAvatar(file: File): Promise<ApiResponse<{
avatar_url: string;
expires_in: number;
user: {
id: number;
username: string;
email: string;
avatar: string;
points: number;
role: string;
status: number;
last_login_at?: string;
created_at: string;
updated_at: string;
};
}>> {
const response = await fetch(`${API_BASE_URL}/user/avatar/upload-url`, {
const formData = new FormData();
formData.append('file', file);
const token = typeof window !== 'undefined' ? localStorage.getItem('authToken') : null;
const response = await fetch(`${API_BASE_URL}/user/avatar/upload`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ file_name: fileName }),
headers: {
...(token && { Authorization: `Bearer ${token}` }),
},
body: formData,
});
return response.json();
}
// 更新头像URL
// 更新头像URL用于外部URL场景区别于文件直传
export async function updateAvatarUrl(avatarUrl: string): Promise<ApiResponse<{
id: number;
username: string;
@@ -313,3 +358,79 @@ export async function resetYggdrasilPassword(): Promise<ApiResponse<{
return response.json();
}
// 删除材质
export async function deleteTexture(id: number): Promise<ApiResponse<null>> {
const response = await fetch(`${API_BASE_URL}/texture/${id}`, {
method: 'DELETE',
headers: getAuthHeaders(),
});
return response.json();
}
// 更新材质信息(名称、描述、公开性)
// 对应后端 PUT /texture/{id}is_public 为布尔值时会更新该字段,会影响公开/隐藏状态
export async function updateTexture(id: number, data: {
name?: string;
description?: string;
is_public?: boolean;
}): Promise<ApiResponse<Texture>> {
const response = await fetch(`${API_BASE_URL}/texture/${id}`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(data),
});
return response.json();
}
// 发送邮箱验证码
// type: register | reset_password | change_email
export async function sendVerificationCode(email: string, type: 'register' | 'reset_password' | 'change_email'): Promise<ApiResponse<null>> {
const response = await fetch(`${API_BASE_URL}/auth/send-code`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, type }),
});
return response.json();
}
// 更换邮箱
export async function changeEmail(newEmail: string, verificationCode: string): Promise<ApiResponse<{
id: number;
username: string;
email: string;
avatar: string;
points: number;
role: string;
status: number;
last_login_at?: string;
created_at: string;
updated_at: string;
}>> {
const response = await fetch(`${API_BASE_URL}/user/change-email`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ new_email: newEmail, verification_code: verificationCode }),
});
return response.json();
}
// 重置密码(通过邮箱验证码)
// 对应后端 POST /auth/reset-password无需 JWT
export async function resetPassword(email: string, verificationCode: string, newPassword: string): Promise<ApiResponse<null>> {
const response = await fetch(`${API_BASE_URL}/auth/reset-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email,
verification_code: verificationCode,
new_password: newPassword,
}),
});
return response.json();
}

View File

@@ -32,7 +32,7 @@ const config: Config = {
},
fontFamily: {
'minecraft': ['Minecraft', 'monospace'],
'sans': ['Inter', 'system-ui', 'sans-serif'],
'sans': ['-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif'],
},
animation: {
'float': 'float 3s ease-in-out infinite',