14 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
29 changed files with 1500 additions and 1645 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

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"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { 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; export default nextConfig;

View File

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

View File

@@ -1,15 +1,23 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect, Suspense } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { EyeIcon, EyeSlashIcon, CheckCircleIcon, XCircleIcon } from '@heroicons/react/24/outline'; import { EyeIcon, EyeSlashIcon, CheckCircleIcon, XCircleIcon } from '@heroicons/react/24/outline';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { errorManager } from '@/components/ErrorNotification'; import { errorManager } from '@/components/ErrorNotification';
import SliderCaptcha from '@/components/SliderCaptcha'; 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 [isLoginMode, setIsLoginMode] = useState(true);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
username: '', username: '',
@@ -31,9 +39,27 @@ export default function AuthPage() {
const [showCaptcha, setShowCaptcha] = useState(false); const [showCaptcha, setShowCaptcha] = useState(false);
const [isCaptchaVerified, setIsCaptchaVerified] = useState(false); const [isCaptchaVerified, setIsCaptchaVerified] = useState(false);
const [captchaId, setCaptchaId] = useState<string | undefined>(); 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 { login, register } = useAuth();
const router = useRouter(); 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(() => { useEffect(() => {
let interval: NodeJS.Timeout; let interval: NodeJS.Timeout;
@@ -88,7 +114,7 @@ export default function AuthPage() {
if (!formData.email.trim()) { if (!formData.email.trim()) {
newErrors.email = '邮箱不能为空'; newErrors.email = '邮箱不能为空';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) { } else if (!EMAIL_REGEX.test(formData.email)) {
newErrors.email = '请输入有效的邮箱地址'; newErrors.email = '请输入有效的邮箱地址';
} }
@@ -130,26 +156,15 @@ export default function AuthPage() {
const passwordStrength = getPasswordStrength(); const passwordStrength = getPasswordStrength();
const handleSendCode = async () => { const handleSendCode = async () => {
if (!formData.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) { if (!formData.email || !EMAIL_REGEX.test(formData.email)) {
setErrors(prev => ({ ...prev, email: '请输入有效的邮箱地址' })); setErrors(prev => ({ ...prev, email: '请输入有效的邮箱地址' }));
return; return;
} }
setIsSendingCode(true); setIsSendingCode(true);
try { try {
const response = await fetch('/api/v1/auth/send-code', { const data = await sendVerificationCode(formData.email, 'register');
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: formData.email,
type: 'register'
}),
});
const data = await response.json();
if (data.code === 200) { if (data.code === 200) {
setCodeTimer(60); setCodeTimer(60);
errorManager.showSuccess('验证码已发送到您的邮箱'); errorManager.showSuccess('验证码已发送到您的邮箱');
@@ -157,7 +172,7 @@ export default function AuthPage() {
setErrors(prev => ({ ...prev, email: data.message || '发送验证码失败' })); setErrors(prev => ({ ...prev, email: data.message || '发送验证码失败' }));
errorManager.showError(data.message || '发送验证码失败'); errorManager.showError(data.message || '发送验证码失败');
} }
} catch (error) { } catch {
setErrors(prev => ({ ...prev, email: '发送验证码失败,请稍后重试' })); setErrors(prev => ({ ...prev, email: '发送验证码失败,请稍后重试' }));
errorManager.showError('发送验证码失败,请稍后重试'); errorManager.showError('发送验证码失败,请稍后重试');
} finally { } finally {
@@ -165,9 +180,10 @@ export default function AuthPage() {
} }
}; };
const handleCaptchaVerify = (success: boolean) => { const handleCaptchaVerify = (success: boolean, verifiedCaptchaId?: string) => {
if (success) { if (success) {
setIsCaptchaVerified(true); setIsCaptchaVerified(true);
setCaptchaId(verifiedCaptchaId);
setShowCaptcha(false); setShowCaptcha(false);
// 验证码验证成功后,继续注册流程 // 验证码验证成功后,继续注册流程
handleRegisterAfterCaptcha(); handleRegisterAfterCaptcha();
@@ -255,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 ( return (
<div className="min-h-screen flex"> <div className="min-h-screen flex">
{/* Left Side - Orange Section */} {/* Left Side - Orange Section */}
@@ -619,9 +709,13 @@ export default function AuthPage() {
</span> </span>
</label> </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> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
@@ -646,13 +740,21 @@ export default function AuthPage() {
/> />
<span className="text-sm text-gray-600 dark:text-gray-400"> <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> </span>
</label> </label>
{errors.agreeToTerms && ( {errors.agreeToTerms && (
@@ -766,6 +868,102 @@ export default function AuthPage() {
/> />
)} )}
{/* 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> </div>
); );
} }
export default function AuthPage() {
return (
<Suspense fallback={<div className="min-h-screen flex items-center justify-center">Loading...</div>}>
<AuthForm />
</Suspense>
);
}

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, CloudArrowUpIcon,
ShareIcon, ShareIcon,
CubeIcon, CubeIcon,
UserGroupIcon,
SparklesIcon, SparklesIcon,
RocketLaunchIcon RocketLaunchIcon
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { Carrot } from 'lucide-react';
export default function Home() { export default function Home() {
const { scrollYProgress } = useScroll(); const { scrollYProgress } = useScroll();
@@ -103,7 +103,7 @@ export default function Home() {
> >
<div className="relative"> <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"> <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> </div>
<motion.div <motion.div
className="absolute -inset-2 bg-gradient-to-br from-orange-400/30 to-amber-500/30 rounded-3xl blur-lg" className="absolute -inset-2 bg-gradient-to-br from-orange-400/30 to-amber-500/30 rounded-3xl blur-lg"
@@ -282,23 +282,15 @@ export default function Home() {
<p className="text-xl text-white/90 mb-8 max-w-2xl mx-auto font-light"> <p className="text-xl text-white/90 mb-8 max-w-2xl mx-auto font-light">
CarrotSkinMinecraft皮肤管理平台 CarrotSkinMinecraft皮肤管理平台
</p> </p>
<div className="flex flex-col sm:flex-row gap-4 justify-center"> <div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link <Link
href="/register" href="/register"
className="group bg-white text-orange-600 hover:bg-gray-100 font-bold py-4 px-8 rounded-2xl transition-all duration-300 inline-flex items-center space-x-2 shadow-2xl" className="group bg-white text-orange-600 hover:bg-gray-100 font-bold py-4 px-8 rounded-2xl transition-all duration-300 inline-flex items-center space-x-2 shadow-2xl"
> >
<span></span> <span></span>
<ArrowRightIcon className="w-5 h-5 group-hover:translate-x-1 transition-transform" /> <ArrowRightIcon className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
</Link> </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> </div>
</motion.div> </motion.div>
</section> </section>

View File

@@ -12,32 +12,30 @@ import {
XMarkIcon, XMarkIcon,
UserIcon, UserIcon,
PhotoIcon, PhotoIcon,
HeartIcon,
TrashIcon,
PencilIcon, PencilIcon,
KeyIcon, KeyIcon,
EnvelopeIcon, EnvelopeIcon,
CloudArrowUpIcon, CloudArrowUpIcon,
EyeIcon,
ArrowDownTrayIcon,
ArrowLeftOnRectangleIcon ArrowLeftOnRectangleIcon
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { import {
getMyTextures, getMyTextures,
getFavoriteTextures, getFavoriteTextures,
toggleFavorite, toggleFavorite,
getProfiles, getProfiles,
createProfile, createProfile,
updateProfile, updateProfile,
deleteProfile, deleteProfile,
setActiveProfile,
getUserProfile, getUserProfile,
updateUserProfile, updateUserProfile,
uploadTexture, uploadTexture,
getTexture, getTexture,
generateAvatarUploadUrl, uploadAvatar,
updateAvatarUrl,
resetYggdrasilPassword, resetYggdrasilPassword,
deleteTexture,
updateTexture,
sendVerificationCode,
changeEmail,
type Texture, type Texture,
type Profile type Profile
} from '@/lib/api'; } from '@/lib/api';
@@ -76,14 +74,14 @@ export default function ProfilePage() {
const [showCreateCharacter, setShowCreateCharacter] = useState(false); const [showCreateCharacter, setShowCreateCharacter] = useState(false);
const [showUploadSkin, setShowUploadSkin] = useState(false); const [showUploadSkin, setShowUploadSkin] = useState(false);
const [newCharacterName, setNewCharacterName] = useState(''); const [newCharacterName, setNewCharacterName] = useState('');
const [newSkinData, setNewSkinData] = useState({ const [, setNewSkinData] = useState({
name: '', name: '',
description: '', description: '',
type: 'SKIN' as 'SKIN' | 'CAPE', type: 'SKIN' as 'SKIN' | 'CAPE',
is_public: false, is_public: false,
is_slim: 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 [editingProfile, setEditingProfile] = useState<string | null>(null);
const [editProfileName, setEditProfileName] = useState(''); const [editProfileName, setEditProfileName] = useState('');
const [uploadProgress, setUploadProgress] = useState(0); const [uploadProgress, setUploadProgress] = useState(0);
@@ -96,8 +94,15 @@ export default function ProfilePage() {
const [yggdrasilPassword, setYggdrasilPassword] = useState<string>(''); const [yggdrasilPassword, setYggdrasilPassword] = useState<string>('');
const [showYggdrasilPassword, setShowYggdrasilPassword] = useState<boolean>(false); const [showYggdrasilPassword, setShowYggdrasilPassword] = useState<boolean>(false);
const [isResettingYggdrasilPassword, setIsResettingYggdrasilPassword] = 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(() => { useEffect(() => {
@@ -178,23 +183,54 @@ export default function ProfilePage() {
}; };
const handleToggleSkinVisibility = async (skinId: number) => { const handleToggleSkinVisibility = async (skinId: number) => {
const skin = mySkins.find(s => s.id === skinId);
if (!skin) return;
const newIsPublic = !skin.is_public;
// 先乐观更新本地态,失败再回滚
setMySkins(prev => prev.map(s =>
s.id === skinId ? { ...s, is_public: newIsPublic } : s
));
try { try {
const skin = mySkins.find(s => s.id === skinId); const response = await updateTexture(skinId, { is_public: newIsPublic });
if (!skin) return; if (response.code === 200) {
// 以后端返回为准
// TODO: 添加更新皮肤API调用 setMySkins(prev => prev.map(s =>
setMySkins(prev => prev.map(skin => s.id === skinId ? { ...s, is_public: response.data.is_public } : s
skin.id === skinId ? { ...skin, is_public: !skin.is_public } : skin ));
)); 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) { } catch (error) {
console.error('切换皮肤可见性失败:', 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) => { const handleDeleteSkin = async (skinId: number) => {
if (!confirm('确定要删除这个皮肤吗?')) return; if (!confirm('确定要删除这个皮肤吗?')) return;
setMySkins(prev => prev.filter(skin => skin.id !== skinId)); 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) => { 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 }) => { const handleUploadSkin = async (file: File, data: { name: string; description: string; type: 'SKIN' | 'CAPE'; is_public: boolean; is_slim: boolean }) => {
if (!file || !data.name.trim()) { if (!file || !data.name.trim()) {
messageManager.warning('请选择皮肤文件并输入皮肤名称', { duration: 3000 }); messageManager.warning('请选择皮肤文件并输入皮肤名称', { duration: 3000 });
@@ -296,52 +325,31 @@ export default function ProfilePage() {
const handleUploadAvatar = async () => { const handleUploadAvatar = async () => {
if (!avatarFile) return; if (!avatarFile) return;
setIsUploadingAvatar(true); setIsUploadingAvatar(true);
setAvatarUploadProgress(0); setAvatarUploadProgress(0);
try { 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(() => { const progressInterval = setInterval(() => {
setAvatarUploadProgress(prev => Math.min(prev + 20, 80)); setAvatarUploadProgress(prev => Math.min(prev + 20, 80));
}, 200); }, 200);
// 上传文件到预签名URL // 直接上传文件到后端,后端写入对象存储并更新用户头像
const formData = new FormData(); const response = await uploadAvatar(avatarFile);
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('文件上传失败');
}
clearInterval(progressInterval); clearInterval(progressInterval);
setAvatarUploadProgress(100); setAvatarUploadProgress(100);
// 更新用户头像URL
const response = await updateAvatarUrl(avatar_url);
if (response.code === 200) { 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 }); messageManager.success('头像上传成功!', { duration: 3000 });
} else { } else {
throw new Error(response.message || '更新头像URL失败'); throw new Error(response.message || '头像上传失败');
} }
} catch (error) { } catch (error) {
console.error('头像上传失败:', error); console.error('头像上传失败:', error);
messageManager.error(error instanceof Error ? error.message : '头像上传失败,请稍后重试', { duration: 3000 }); messageManager.error(error instanceof Error ? error.message : '头像上传失败,请稍后重试', { duration: 3000 });
@@ -373,12 +381,14 @@ export default function ProfilePage() {
const handleResetYggdrasilPassword = async () => { const handleResetYggdrasilPassword = async () => {
if (!confirm('确定要重置Yggdrasil密码吗这将生成一个新的密码。')) return; if (!confirm('确定要重置Yggdrasil密码吗这将生成一个新的密码。')) return;
setIsResettingYggdrasilPassword(true); setIsResettingYggdrasilPassword(true);
try { try {
const response = await resetYggdrasilPassword(); const response = await resetYggdrasilPassword();
if (response.code === 200) { if (response.code === 200) {
setYggdrasilPassword(response.data.password);
setShowYggdrasilPassword(true);
messageManager.success('Yggdrasil密码重置成功请妥善保管新密码。', { duration: 5000 }); messageManager.success('Yggdrasil密码重置成功请妥善保管新密码。', { duration: 5000 });
} else { } else {
throw new Error(response.message || '重置Yggdrasil密码失败'); 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 () => { const handleCreateCharacter = async () => {
if (!newCharacterName.trim()) { if (!newCharacterName.trim()) {
messageManager.warning('请输入角色名称', { duration: 3000 }); 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 () => { const handleEditCharacter = async () => {
if (!editProfileName.trim()) { if (!editProfileName.trim()) {
messageManager.warning('请输入角色名称', { duration: 3000 }); messageManager.warning('请输入角色名称', { duration: 3000 });
@@ -590,7 +661,6 @@ export default function ProfilePage() {
onSave={handleEditCharacter} onSave={handleEditCharacter}
onCancel={onCancelEdit} onCancel={onCancelEdit}
onDelete={handleDeleteCharacter} onDelete={handleDeleteCharacter}
onSetActive={handleSetActiveCharacter}
onSelectSkin={setShowSkinSelector} onSelectSkin={setShowSkinSelector}
onEditNameChange={setEditProfileName} onEditNameChange={setEditProfileName}
/> />
@@ -640,6 +710,7 @@ export default function ProfilePage() {
<div className="flex items-center space-x-6"> <div className="flex items-center space-x-6">
<div className="relative"> <div className="relative">
{userProfile?.avatar ? ( {userProfile?.avatar ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img <img
src={userProfile.avatar} src={userProfile.avatar}
alt={userProfile.username} alt={userProfile.username}
@@ -833,10 +904,15 @@ export default function ProfilePage() {
<span></span> <span></span>
</h3> </h3>
<div className="space-y-3"> <div className="space-y-3">
<motion.button <motion.button
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" 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 }} whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
onClick={() => {
setChangeEmailForm({ newEmail: '', code: '' });
setEmailCodeCountdown(0);
setShowChangeEmailModal(true);
}}
> >
<span></span> <span></span>
<EnvelopeIcon className="w-5 h-5" /> <EnvelopeIcon className="w-5 h-5" />
@@ -899,7 +975,7 @@ export default function ProfilePage() {
> >
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-bold text-gray-900 dark:text-white"> <h3 className="text-xl font-bold text-gray-900 dark:text-white">
"{currentProfile?.name}" &ldquo;{currentProfile?.name}&rdquo;
</h3> </h3>
<button <button
onClick={() => setShowSkinSelector(null)} onClick={() => setShowSkinSelector(null)}
@@ -913,19 +989,35 @@ export default function ProfilePage() {
<motion.div <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" 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 }} whileHover={{ scale: 1.02 }}
onClick={() => { onClick={async () => {
// 移除皮肤 if (!currentProfile) return;
if (currentProfile) { const prevSkinId = currentProfile.skin_id;
updateProfile(currentProfile.uuid, { skin_id: undefined }); // 乐观更新本地态
setProfiles(prev => prev.map(p => setProfiles(prev => prev.map(p =>
p.uuid === currentProfile.uuid ? { ...p, skin_id: undefined } : p p.uuid === currentProfile.uuid ? { ...p, skin_id: undefined } : p
));
setProfileSkins(prev => {
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
)); ));
setProfileSkins(prev => { messageManager.error('移除皮肤失败,请稍后重试', { duration: 3000 });
const newSkins = { ...prev };
delete newSkins[currentProfile.uuid];
return newSkins;
});
setShowSkinSelector(null);
} }
}} }}
> >
@@ -934,35 +1026,50 @@ export default function ProfilePage() {
<p className="text-sm text-gray-500 dark:text-gray-400"></p> <p className="text-sm text-gray-500 dark:text-gray-400"></p>
</div> </div>
</motion.div> </motion.div>
{availableSkins.map((skin) => ( {availableSkins.map((skin) => (
<motion.div <motion.div
key={skin.id} 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" 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 }} whileHover={{ scale: 1.02 }}
onClick={() => { onClick={async () => {
// 分配皮肤给角色 if (!currentProfile) return;
if (currentProfile) { const prevSkinId = currentProfile.skin_id;
updateProfile(currentProfile.uuid, { skin_id: skin.id }); // 乐观更新
setProfiles(prev => prev.map(p => setProfiles(prev => prev.map(p =>
p.uuid === currentProfile.uuid ? { ...p, skin_id: skin.id } : p p.uuid === currentProfile.uuid ? { ...p, skin_id: skin.id } : p
));
setProfileSkins(prev => ({
...prev,
[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
)); ));
setProfileSkins(prev => ({ messageManager.error('设置皮肤失败,请稍后重试', { duration: 3000 });
...prev,
[currentProfile.uuid]: { url: skin.url, isSlim: skin.is_slim }
}));
setShowSkinSelector(null);
} }
}} }}
> >
<SkinViewer <div className="w-full h-full flex items-center justify-center">
skinUrl={skin.url} <SkinViewer
isSlim={skin.is_slim} skinUrl={skin.url}
width={200} isSlim={skin.is_slim}
height={200} width={180}
className="w-full h-full" height={180}
autoRotate={false} autoRotate={false}
/> />
</div>
<div className="absolute bottom-0 left-0 right-0 bg-black/50 text-white text-xs p-2 text-center"> <div className="absolute bottom-0 left-0 right-0 bg-black/50 text-white text-xs p-2 text-center">
{skin.name} {skin.name}
</div> </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) { if (!isAuthenticated) {
return ( 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="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 */} {/* Skin Selector Modal */}
{renderSkinSelector()} {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> </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 { useState, useEffect, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { MagnifyingGlassIcon, EyeIcon, HeartIcon, ArrowDownTrayIcon, SparklesIcon, FunnelIcon, ArrowsUpDownIcon } from '@heroicons/react/24/outline'; import { MagnifyingGlassIcon, FunnelIcon, ArrowsUpDownIcon } from '@heroicons/react/24/outline';
import { HeartIcon as HeartIconSolid } from '@heroicons/react/24/solid'; import { Palette } from 'lucide-react';
import SkinViewer from '@/components/SkinViewer';
import SkinDetailModal from '@/components/SkinDetailModal'; import SkinDetailModal from '@/components/SkinDetailModal';
import SkinCard from '@/components/SkinCard'; import SkinCard from '@/components/SkinCard';
import { searchTextures, toggleFavorite, type Texture } from '@/lib/api'; import { searchTextures, toggleFavorite, type Texture } from '@/lib/api';
@@ -307,7 +306,7 @@ export default function SkinsPage() {
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ delay: 0.2 }} transition={{ delay: 0.2 }}
> >
{textures.map((texture, index) => { {textures.map((texture) => {
const isFavorited = favoritedIds.has(texture.id); const isFavorited = favoritedIds.has(texture.id);
return ( return (
@@ -335,7 +334,9 @@ export default function SkinsPage() {
exit={{ opacity: 0, scale: 0.9 }} exit={{ opacity: 0, scale: 0.9 }}
className="text-center py-16" className="text-center py-16"
> >
<div className="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 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
</h3> </h3>

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { motion, MotionProps } from 'framer-motion'; import { motion, MotionProps } from 'framer-motion';
import { ReactNode, useState, useEffect } from 'react'; import { ReactNode, useState } from 'react';
import { AnimatePresence } from 'framer-motion'; import { AnimatePresence } from 'framer-motion';
interface EnhancedButtonProps extends Omit<MotionProps, 'onClick'> { interface EnhancedButtonProps extends Omit<MotionProps, 'onClick'> {
@@ -15,7 +15,6 @@ interface EnhancedButtonProps extends Omit<MotionProps, 'onClick'> {
icon?: ReactNode; icon?: ReactNode;
iconPosition?: 'left' | 'right'; iconPosition?: 'left' | 'right';
ripple?: boolean; ripple?: boolean;
sound?: boolean;
haptic?: boolean; haptic?: boolean;
className?: string; className?: string;
type?: 'button' | 'submit' | 'reset'; type?: 'button' | 'submit' | 'reset';
@@ -32,7 +31,6 @@ export default function EnhancedButton({
icon, icon,
iconPosition = 'left', iconPosition = 'left',
ripple = true, ripple = true,
sound = true,
haptic = true, haptic = true,
className = '', className = '',
type = 'button', type = 'button',
@@ -41,37 +39,6 @@ export default function EnhancedButton({
const [isProcessing, setIsProcessing] = useState(false); const [isProcessing, setIsProcessing] = useState(false);
const [ripples, setRipples] = useState<Array<{ id: number; x: number; y: number }>>([]); 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 = () => { const triggerHaptic = () => {
if (haptic && navigator.vibrate) { if (haptic && navigator.vibrate) {
@@ -102,7 +69,6 @@ export default function EnhancedButton({
if (disabled || loading || isProcessing) return; if (disabled || loading || isProcessing) return;
createRipple(e); createRipple(e);
playSound('click');
triggerHaptic(); triggerHaptic();
if (onClick) { if (onClick) {
@@ -113,10 +79,8 @@ export default function EnhancedButton({
// 如果返回的是 Promise等待它完成 // 如果返回的是 Promise等待它完成
if (result instanceof Promise) { if (result instanceof Promise) {
await result; await result;
playSound('success');
} }
} catch (error) { } catch (error) {
playSound('error');
console.error('Button click error:', error); console.error('Button click error:', error);
} finally { } finally {
setIsProcessing(false); setIsProcessing(false);

View File

@@ -192,7 +192,6 @@ export function ErrorNotification({ message, type = 'error', duration = 5000, on
class ErrorManager { class ErrorManager {
private static instance: ErrorManager; private static instance: ErrorManager;
private listeners: Array<(notification: ErrorNotificationProps & { id: string }) => void> = []; private listeners: Array<(notification: ErrorNotificationProps & { id: string }) => void> = [];
private soundEnabled: boolean = true;
static getInstance(): ErrorManager { static getInstance(): ErrorManager {
if (!ErrorManager.instance) { if (!ErrorManager.instance) {
@@ -201,51 +200,19 @@ class ErrorManager {
return ErrorManager.instance; 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) { showError(message: string, duration?: number) {
this.playSound('error');
this.showNotification(message, 'error', duration); this.showNotification(message, 'error', duration);
} }
showWarning(message: string, duration?: number) { showWarning(message: string, duration?: number) {
this.playSound('warning');
this.showNotification(message, 'warning', duration); this.showNotification(message, 'warning', duration);
} }
showSuccess(message: string, duration?: number) { showSuccess(message: string, duration?: number) {
this.playSound('success');
this.showNotification(message, 'success', duration); this.showNotification(message, 'success', duration);
} }
showInfo(message: string, duration?: number) { showInfo(message: string, duration?: number) {
this.playSound('info');
this.showNotification(message, 'info', duration); this.showNotification(message, 'info', duration);
} }
@@ -266,10 +233,6 @@ class ErrorManager {
this.listeners = this.listeners.filter(l => l !== listener); this.listeners = this.listeners.filter(l => l !== listener);
}; };
} }
setSoundEnabled(enabled: boolean) {
this.soundEnabled = enabled;
}
} }
export const errorManager = ErrorManager.getInstance(); export const errorManager = ErrorManager.getInstance();
@@ -277,7 +240,6 @@ export const errorManager = ErrorManager.getInstance();
// 增强的错误提示容器组件 // 增强的错误提示容器组件
export function ErrorNotificationContainer() { export function ErrorNotificationContainer() {
const [notifications, setNotifications] = useState<Array<ErrorNotificationProps & { id: string }>>([]); const [notifications, setNotifications] = useState<Array<ErrorNotificationProps & { id: string }>>([]);
const [soundEnabled, setSoundEnabled] = useState(true);
useEffect(() => { useEffect(() => {
const unsubscribe = errorManager.subscribe((notification) => { const unsubscribe = errorManager.subscribe((notification) => {
@@ -287,10 +249,6 @@ export function ErrorNotificationContainer() {
return unsubscribe; return unsubscribe;
}, []); }, []);
useEffect(() => {
errorManager.setSoundEnabled(soundEnabled);
}, [soundEnabled]);
const removeNotification = (id: string) => { const removeNotification = (id: string) => {
setNotifications(prev => prev.filter(n => n.id !== id)); setNotifications(prev => prev.filter(n => n.id !== id));
}; };

View File

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

View File

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

View File

@@ -7,8 +7,7 @@ import {
ExclamationTriangleIcon, ExclamationTriangleIcon,
CheckCircleIcon, CheckCircleIcon,
InformationCircleIcon, InformationCircleIcon,
XCircleIcon, XCircleIcon
BellIcon
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
export type MessageType = 'success' | 'error' | 'warning' | 'info' | 'loading'; 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 { useRouter, usePathname } from 'next/navigation';
import { Bars3Icon, XMarkIcon, UserCircleIcon } from '@heroicons/react/24/outline'; import { Bars3Icon, XMarkIcon, UserCircleIcon } from '@heroicons/react/24/outline';
import { motion, AnimatePresence, useScroll, useTransform, useSpring } from 'framer-motion'; import { motion, AnimatePresence, useScroll, useTransform, useSpring } from 'framer-motion';
import { Carrot } from 'lucide-react';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
export default function Navbar() { export default function Navbar() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isHidden, setIsHidden] = useState(false); const [isHidden, setIsHidden] = useState(false);
const [isScrolled, setIsScrolled] = useState(false); const [isScrolled, setIsScrolled] = useState(false);
const [showScrollTop, setShowScrollTop] = useState(false);
const [navbarHeight, setNavbarHeight] = useState(0);
const [scrollProgress, setScrollProgress] = useState(0); const [scrollProgress, setScrollProgress] = useState(0);
const navbarRef = useRef<HTMLElement>(null); const navbarRef = useRef<HTMLElement>(null);
const { user, isAuthenticated, logout } = useAuth(); const { user, isAuthenticated, isLoading: isAuthLoading, logout } = useAuth();
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const { scrollY } = useScroll(); const { scrollY } = useScroll();
@@ -24,7 +23,6 @@ export default function Navbar() {
const springConfig = { stiffness: 300, damping: 30 }; const springConfig = { stiffness: 300, damping: 30 };
const navbarY = useSpring(useTransform(scrollY, [0, 100], [0, -100]), springConfig); const navbarY = useSpring(useTransform(scrollY, [0, 100], [0, -100]), springConfig);
const navbarOpacity = useSpring(useTransform(scrollY, [0, 50], [1, 0.95]), springConfig); const navbarOpacity = useSpring(useTransform(scrollY, [0, 50], [1, 0.95]), springConfig);
const scrollProgressSpring = useSpring(scrollProgress, springConfig);
// 在auth页面隐藏navbar // 在auth页面隐藏navbar
const isAuthPage = pathname === '/auth'; const isAuthPage = pathname === '/auth';
@@ -34,7 +32,6 @@ export default function Navbar() {
const updateHeight = () => { const updateHeight = () => {
if (navbarRef.current) { if (navbarRef.current) {
const height = navbarRef.current.offsetHeight; const height = navbarRef.current.offsetHeight;
setNavbarHeight(height);
document.documentElement.style.setProperty('--navbar-height', `${height}px`); document.documentElement.style.setProperty('--navbar-height', `${height}px`);
} }
}; };
@@ -69,9 +66,6 @@ export default function Navbar() {
// 检测是否滚动到顶部 // 检测是否滚动到顶部
setIsScrolled(currentScrollY > 20); setIsScrolled(currentScrollY > 20);
// 显示返回顶部按钮滚动超过300px
setShowScrollTop(currentScrollY > 300);
// 更敏感的隐藏逻辑:只要往下滚动就隐藏,不管滚动多少 // 更敏感的隐藏逻辑:只要往下滚动就隐藏,不管滚动多少
if (!isAuthPage && currentScrollY > lastScrollY && currentScrollY > 10) { if (!isAuthPage && currentScrollY > lastScrollY && currentScrollY > 10) {
setIsHidden(true); setIsHidden(true);
@@ -108,13 +102,6 @@ export default function Navbar() {
return null; return null;
} }
const scrollToTop = () => {
window.scrollTo({
top: 0,
behavior: 'smooth',
});
};
const navItems = [ const navItems = [
{ href: '/', label: '首页', icon: null }, { href: '/', label: '首页', icon: null },
{ href: '/skins', label: '皮肤库', icon: null }, { href: '/skins', label: '皮肤库', icon: null },
@@ -161,7 +148,7 @@ export default function Navbar() {
whileHover={{ rotate: 5, scale: 1.05 }} whileHover={{ rotate: 5, scale: 1.05 }}
transition={{ type: 'spring', stiffness: 300 }} 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 <motion.div
className="absolute inset-0 bg-gradient-to-br from-white/20 to-transparent" className="absolute inset-0 bg-gradient-to-br from-white/20 to-transparent"
initial={{ x: '-100%', y: '-100%' }} 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"> <div className="flex items-center space-x-4">
<motion.div <motion.div
whileHover={{ scale: 1.05 }} whileHover={{ scale: 1.05 }}
@@ -232,6 +224,7 @@ export default function Navbar() {
className="relative" className="relative"
whileHover={{ scale: 1.1, rotate: 5 }} whileHover={{ scale: 1.1, rotate: 5 }}
> >
{/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={user.avatar} src={user.avatar}
alt={user.username} alt={user.username}
@@ -400,6 +393,7 @@ export default function Navbar() {
> >
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
{user?.avatar ? ( {user?.avatar ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img <img
src={user.avatar} src={user.avatar}
alt={user.username} alt={user.username}
@@ -463,7 +457,7 @@ export default function Navbar() {
</AnimatePresence> </AnimatePresence>
</motion.nav> </motion.nav>
{/* 返回顶部按钮 */} {/* 返回顶部按钮 - 已注释掉,如需使用请恢复 showScrollTop 状态 */}
{/* <AnimatePresence> {/* <AnimatePresence>
{showScrollTop && ( {showScrollTop && (
<motion.button <motion.button
@@ -472,7 +466,7 @@ export default function Navbar() {
exit={{ opacity: 0, scale: 0.8, y: 20 }} exit={{ opacity: 0, scale: 0.8, y: 20 }}
whileHover={{ scale: 1.1, y: -2 }} whileHover={{ scale: 1.1, y: -2 }}
whileTap={{ scale: 0.9 }} 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" 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 <motion.div

View File

@@ -1,9 +1,7 @@
'use client'; 'use client';
import { motion, AnimatePresence } from 'framer-motion'; import { motion } from 'framer-motion';
import { usePathname, useSearchParams } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { useEffect, useState, useRef } from 'react';
import { useRouter } from 'next/navigation';
interface PageTransitionProps { interface PageTransitionProps {
children: React.ReactNode; children: React.ReactNode;
@@ -11,163 +9,15 @@ interface PageTransitionProps {
export default function PageTransition({ children }: PageTransitionProps) { export default function PageTransition({ children }: PageTransitionProps) {
const pathname = usePathname(); 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 ( return (
<> <motion.div
<AnimatePresence mode="wait"> key={pathname}
{isNavigating && ( initial={{ opacity: 0, y: 8 }}
<motion.div animate={{ opacity: 1, y: 0 }}
key="loading" transition={{ duration: 0.3, ease: 'easeOut' }}
variants={getLoadingVariants()} >
initial="initial" {children}
animate="animate" </motion.div>
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 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
...
</motion.p>
</div>
</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

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

View File

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

View File

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

View File

@@ -6,11 +6,11 @@ import { API_BASE_URL } from '@/lib/api';
/** /**
* 滑块验证码组件属性接口定义 * 滑块验证码组件属性接口定义
* @interface SliderCaptchaProps * @interface SliderCaptchaProps
* @property {function} onVerify - 验证结果回调函数,参数为验证是否成功 * @property {function} onVerify - 验证结果回调函数,参数为验证是否成功及验证码ID成功时
* @property {function} onClose - 关闭验证码组件的回调函数 * @property {function} onClose - 关闭验证码组件的回调函数
*/ */
interface SliderCaptchaProps { interface SliderCaptchaProps {
onVerify: (success: boolean) => void; onVerify: (success: boolean, captchaId?: string) => void;
onClose: () => void; onClose: () => void;
} }
@@ -76,18 +76,19 @@ export const SliderCaptcha: React.FC<SliderCaptchaProps> = ({ onVerify, onClose
const response = await axios.get(`${API_BASE_URL}/captcha/generate`, { const response = await axios.get(`${API_BASE_URL}/captcha/generate`, {
withCredentials: true // 关键:允许跨域携带凭证 withCredentials: true // 关键:允许跨域携带凭证
}); });
const { code, msg: resMsg, captcha_id, mBase64, tBase64, y } = response.data; const { code, message: resMsg, data } = response.data;
const { masterImage, tileImage, captchaId, y } = data;
// 后端返回成功状态code=200 // 后端返回成功状态code=200
if (code === 200) { if (code === 200) {
// 设置背景图 // 设置背景图
setBackgroundImage(mBase64); setBackgroundImage(masterImage);
// 设置拼图图片 // 设置拼图图片
setPuzzleImage(tBase64); setPuzzleImage(tileImage);
// 设置拼图y坐标从后端获取以背景图左上角为原点 // 设置拼图y坐标从后端获取以背景图左上角为原点
setPuzzleY(y); setPuzzleY(y);
// 设置进程ID用于后续验证 // 设置进程ID用于后续验证
setProcessId(captcha_id); setProcessId(captchaId);
// 随机生成拼图x坐标确保拼图在背景图内 // 随机生成拼图x坐标确保拼图在背景图内
// setPuzzlePosition(Math.random() * (CANVAS_WIDTH - 50 - 50) + 50); // setPuzzlePosition(Math.random() * (CANVAS_WIDTH - 50 - 50) + 50);
// 保存后端返回的提示信息 // 保存后端返回的提示信息
@@ -163,47 +164,59 @@ export const SliderCaptcha: React.FC<SliderCaptchaProps> = ({ onVerify, onClose
try { try {
// 向后端发送验证请求参数为滑块位置x坐标和进程ID // 向后端发送验证请求参数为滑块位置x坐标和进程ID
// 使用sliderPosition作为dx值这是拼图块左上角的位置
const response = await axios.post(`${API_BASE_URL}/captcha/verify`, { const response = await axios.post(`${API_BASE_URL}/captcha/verify`, {
dx: sliderPosition, // 滑块位置拼图左上角x坐标以背景图左上角为原点 dx: sliderPosition, // 滑块位置拼图左上角x坐标以背景图左上角为原点
captcha_id: processId // 验证码进程ID captchaId: processId // 验证码进程ID
},{ withCredentials: true }); },{ withCredentials: true });
const { code, msg: resMsg, data } = response.data; const { code, message: resMsg } = response.data;
// 保存后端返回的提示信息 // 保存后端返回的提示信息
setMsg(resMsg); setMsg(resMsg);
// 后端返回成功 code=200 // 根据后端返回的code判断验证结果
// 验证成功code=200
if (code === 200) { if (code === 200) {
// 验证成功data=true
if (data === true) {
setIsVerified(true);
setVerifyResult(true);
// 延迟1.2秒后调用验证成功回调
setTimeout(() => onVerify(true), 1200);
}
// 验证失败data=false
else {
setVerifyResult(false);
setShowError(true);
// 增加尝试次数
setAttempts(prev => prev + 1);
// 1.5秒后重置滑块位置并隐藏错误提示
setTimeout(() => {
setSliderPosition(0);
setShowError(false);
}, 1500);
}
}
// 后端返回参数错误400或系统错误500
else if (code === 400 || code === 500) {
setVerifyResult('error');
setShowError(true);
// 增加尝试次数 // 增加尝试次数
setAttempts(prev => prev + 1); setAttempts(prev => prev + 1);
// 1.5秒后重置滑块位置并隐藏错误提示 // 重置所有状态,确保验证成功状态的纯净性
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(() => { setTimeout(() => {
setSliderPosition(0); setSliderPosition(0);
setShowError(false); 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); }, 1500);
} }
@@ -318,12 +331,12 @@ export const SliderCaptcha: React.FC<SliderCaptchaProps> = ({ onVerify, onClose
// 加载中显示旋转动画 // 加载中显示旋转动画
return <div className="w-5 h-5 border-2 border-blue-300 border-t-blue-600 rounded-full animate-spin" />; return <div className="w-5 h-5 border-2 border-blue-300 border-t-blue-600 rounded-full animate-spin" />;
} }
// 验证成功时,无论其他状态如何,都显示对勾图标
if (isVerified) { if (isVerified) {
// 验证成功显示对勾图标
return <Check className="w-5 h-5 text-green-600" />; return <Check className="w-5 h-5 text-green-600" />;
} }
if (showError) { // 验证失败或错误时显示叉号图标
// 验证失败显示叉号图标 if (showError || verifyResult === 'error') {
return <X className="w-5 h-5 text-red-600" />; return <X className="w-5 h-5 text-red-600" />;
} }
// 默认显示蓝色圆点 // 默认显示蓝色圆点
@@ -332,8 +345,12 @@ export const SliderCaptcha: React.FC<SliderCaptchaProps> = ({ onVerify, onClose
const getStatusText = () => { const getStatusText = () => {
if (verifyResult === 'error' || showError || isVerified) { if (isVerified) {
// 错误、验证失败或成功时显示后端返回的消息 // 验证成功时优先显示成功消息
return msg;
}
if (verifyResult === 'error' || showError) {
// 错误或验证失败时显示后端返回的消息
return msg; return msg;
} }
// 默认显示拖拽提示 // 默认显示拖拽提示
@@ -342,17 +359,21 @@ export const SliderCaptcha: React.FC<SliderCaptchaProps> = ({ onVerify, onClose
const getStatusColor = () => { const getStatusColor = () => {
if (verifyResult === 'error') return 'text-orange-700';
if (isVerified) return 'text-green-700'; if (isVerified) return 'text-green-700';
if (verifyResult === 'error') return 'text-orange-700';
if (showError) return 'text-red-700'; if (showError) return 'text-red-700';
return 'text-gray-600'; return 'text-gray-600';
}; };
const getProgressColor = () => { const getProgressColor = () => {
if (verifyResult === 'error') return 'bg-gradient-to-r from-orange-400 to-orange-500'; // 验证成功时,无论其他状态如何,都显示绿色渐变
if (isVerified) return 'bg-gradient-to-r from-green-400 to-green-500'; if (isVerified) return 'bg-gradient-to-r from-green-400 to-green-500';
if (showError) return 'bg-gradient-to-r from-red-400 to-red-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 'bg-gradient-to-r from-blue-400 to-blue-500';
}; };
@@ -379,28 +400,29 @@ export const SliderCaptcha: React.FC<SliderCaptchaProps> = ({ onVerify, onClose
{/* 背景图片容器尺寸300x200px与后端图片尺寸匹配 */} {/* 背景图片容器尺寸300x200px与后端图片尺寸匹配 */}
<div className="relative bg-gray-200 rounded-lg w-[300px] h-[200px] mb-4 overflow-hidden mx-auto"> <div className="relative bg-gray-200 rounded-lg w-[300px] h-[200px] mb-4 overflow-hidden mx-auto">
{backgroundImage && ( {backgroundImage && (
/* eslint-disable-next-line @next/next/no-img-element */
<img <img
src={backgroundImage} src={backgroundImage}
alt="验证背景" alt="验证背景"
className="h-full w-full object-cover" // 图片填满容器 className="h-full w-full object-cover"
/> />
)} )}
{/* 可移动拼图块 */} {/* 可移动拼图块 */}
{puzzleImage && !isVerified && ( {puzzleImage && (
<div <div
className="absolute transition-all duration-300" className={`absolute ${isDragging ? '' : 'transition-all duration-300'}`}
style={{ style={{
left: `${sliderPosition}px`, // 滑块x位置拼图左上角x坐标 left: `${sliderPosition}px`,
top: `${puzzleY}px`, // 拼图y位置从后端获取拼图左上角y坐标 top: `${puzzleY}px`,
zIndex: 10, zIndex: 10,
}} }}
> >
{/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={puzzleImage} src={puzzleImage}
alt="拼图块" alt="拼图块"
className={`${isVerified ? 'opacity-100' : 'opacity-90'}`} className={`${isVerified ? 'opacity-100' : 'opacity-90'}`}
style={{ 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))' filter: isVerified ? 'drop-shadow(0 0 10px rgba(34, 197, 94, 0.5))' : 'drop-shadow(0 2px 4px rgba(0,0,0,0.3))'
}} }}
/> />
@@ -415,7 +437,7 @@ export const SliderCaptcha: React.FC<SliderCaptchaProps> = ({ onVerify, onClose
<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="relative bg-gray-100 rounded-full h-12 overflow-hidden select-none" ref={trackRef} style={{ width: `${TRACK_WIDTH}px`, margin: '0 auto' }}>
{/* 进度条 */} {/* 进度条 */}
<div <div
className={`absolute left-0 top-0 h-full transition-all duration-200 ease-out ${getProgressColor()}`} className={`absolute left-0 top-0 h-full ${isDragging ? '' : 'transition-all duration-200 ease-out'} ${getProgressColor()}`}
style={{ style={{
width: `${sliderPosition + SLIDER_WIDTH}px`, width: `${sliderPosition + SLIDER_WIDTH}px`,
transform: isDragging ? 'scaleY(1.05)' : 'scaleY(1)', transform: isDragging ? 'scaleY(1.05)' : 'scaleY(1)',
@@ -424,14 +446,13 @@ export const SliderCaptcha: React.FC<SliderCaptchaProps> = ({ onVerify, onClose
/> />
{/* 滑块按钮 */} {/* 滑块按钮 */}
<div <div
className={`absolute top-1 w-10 h-10 bg-white rounded-full shadow-lg cursor-pointer flex items-center justify-center transition-all duration-200 ease-out select-none ${ 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' isDragging ? 'scale-110 shadow-xl' : 'scale-100'
} ${isVerified || verifyResult === 'error' ? 'cursor-default' : 'cursor-grab active:cursor-grabbing'}`} } ${isVerified || verifyResult === 'error' ? 'cursor-default' : 'cursor-grab active:cursor-grabbing'}`}
style={{ left: `${sliderPosition + 2}px`, zIndex: 10 }} style={{ left: `${sliderPosition + 2}px`, zIndex: 10 }}
onMouseDown={handleMouseDown} onMouseDown={verifyResult === 'error' ? undefined : handleMouseDown}
onTouchStart={handleTouchStart} onTouchStart={verifyResult === 'error' ? undefined : handleTouchStart}
ref={sliderRef} ref={sliderRef}
disabled={verifyResult === 'error'}
> >
{getSliderIcon()} {getSliderIcon()}
</div> </div>
@@ -450,7 +471,7 @@ export const SliderCaptcha: React.FC<SliderCaptchaProps> = ({ onVerify, onClose
{/* 底部信息区域 */} {/* 底部信息区域 */}
<div className="px-6 pb-6"> <div className="px-6 pb-6">
<div className="flex items-center justify-between text-xs text-gray-500"> <div className="flex items-center justify-between text-xs text-gray-500">
<span>: {attempts + 1}/5</span> <span>: {attempts}</span>
<span className="flex items-center space-x-1"> <span className="flex items-center space-x-1">
<Shield className="w-3 h-3" /> <Shield className="w-3 h-3" />
<span></span> <span></span>

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { motion } from 'framer-motion'; 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 SkinViewer from '@/components/SkinViewer';
import type { Profile } from '@/lib/api'; import type { Profile } from '@/lib/api';
@@ -15,7 +15,6 @@ interface CharacterCardProps {
onSave: (uuid: string) => void; onSave: (uuid: string) => void;
onCancel: () => void; onCancel: () => void;
onDelete: (uuid: string) => void; onDelete: (uuid: string) => void;
onSetActive: (uuid: string) => void;
onSelectSkin: (uuid: string) => void; onSelectSkin: (uuid: string) => void;
onEditNameChange: (name: string) => void; onEditNameChange: (name: string) => void;
} }
@@ -30,7 +29,6 @@ export default function CharacterCard({
onSave, onSave,
onCancel, onCancel,
onDelete, onDelete,
onSetActive,
onSelectSkin, onSelectSkin,
onEditNameChange onEditNameChange
}: CharacterCardProps) { }: CharacterCardProps) {
@@ -42,25 +40,32 @@ export default function CharacterCard({
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
> >
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
{isEditing ? ( <div className="flex items-center gap-2 flex-1">
<input {isEditing ? (
type="text" <input
value={editName} type="text"
onChange={(e) => onEditNameChange(e.target.value)} value={editName}
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" onChange={(e) => onEditNameChange(e.target.value)}
onBlur={() => onSave(profile.uuid)} className="text-lg font-semibold bg-transparent border-b border-orange-500 focus:outline-none text-gray-900 dark:text-white flex-1"
onKeyPress={(e) => e.key === 'Enter' && onSave(profile.uuid)} onBlur={() => onSave(profile.uuid)}
autoFocus 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 && ( <h3 className="text-lg font-semibold text-gray-900 dark:text-white truncate">{profile.name}</h3>
<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"> <motion.button
<CheckIcon className="w-3 h-3" /> onClick={() => onEdit(profile.uuid, profile.name)}
<span>使</span> className="text-gray-500 hover:text-orange-500 transition-colors"
</span> whileHover={{ scale: 1.1 }}
)} whileTap={{ scale: 0.9 }}
title="改名"
>
<PencilIcon className="w-4 h-4" />
</motion.button>
</>
)}
</div>
</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"> <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">
@@ -68,9 +73,8 @@ export default function CharacterCard({
<SkinViewer <SkinViewer
skinUrl={skinUrl} skinUrl={skinUrl}
isSlim={isSlim} isSlim={isSlim}
width={200} width={120}
height={200} height={120}
className="w-full h-full"
autoRotate={false} autoRotate={false}
/> />
) : ( ) : (
@@ -82,32 +86,10 @@ export default function CharacterCard({
<UserIcon className="w-10 h-10 text-white" /> <UserIcon className="w-10 h-10 text-white" />
</motion.div> </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>
{/* 操作按钮 */} {/* 操作按钮 */}
<div className="flex gap-2"> <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 ? ( {isEditing ? (
<> <>
<motion.button <motion.button
@@ -130,13 +112,12 @@ export default function CharacterCard({
) : ( ) : (
<> <>
<motion.button <motion.button
onClick={() => onEdit(profile.uuid, profile.name)} onClick={() => onSelectSkin(profile.uuid)}
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" 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 }} whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
> >
<PencilIcon className="w-4 h-4 inline mr-1" />
</motion.button> </motion.button>
<motion.button <motion.button
onClick={() => onDelete(profile.uuid)} onClick={() => onDelete(profile.uuid)}

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,9 @@
export 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 { export interface Texture {
id: number; id: number;
uploader_id: number; uploader_id: number;
uploader_username?: string;
name: string; name: string;
description?: string; description?: string;
type: 'SKIN' | 'CAPE'; type: 'SKIN' | 'CAPE';
@@ -24,7 +25,6 @@ export interface Profile {
name: string; name: string;
skin_id?: number; skin_id?: number;
cape_id?: number; cape_id?: number;
is_active: boolean;
last_used_at?: string; last_used_at?: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
@@ -38,6 +38,32 @@ export interface PaginatedResponse<T> {
total_pages: number; 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> { export interface ApiResponse<T> {
code: number; code: number;
message: string; 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(), 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(), 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: { export async function updateProfile(uuid: string, data: {
name?: string; name?: string;
skin_id?: number; skin_id?: number | null;
cape_id?: number; cape_id?: number | null;
}): Promise<ApiResponse<Profile>> { }): Promise<ApiResponse<Profile>> {
const response = await fetch(`${API_BASE_URL}/profile/${uuid}`, { const response = await fetch(`${API_BASE_URL}/profile/${uuid}`, {
method: 'PUT', method: 'PUT',
@@ -180,16 +219,6 @@ export async function deleteProfile(uuid: string): Promise<ApiResponse<null>> {
return response.json(); 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<{ export async function getUserProfile(): Promise<ApiResponse<{
id: number; id: number;
@@ -264,23 +293,39 @@ export async function uploadTexture(file: File, data: {
return response.json(); return response.json();
} }
// 生成头像上传URL // 直接上传头像文件到后端multipart/form-data
export async function generateAvatarUploadUrl(fileName: string): Promise<ApiResponse<{ // 后端接口 POST /user/avatar/upload,由后端负责写入对象存储并更新用户头像
post_url: string; export async function uploadAvatar(file: File): Promise<ApiResponse<{
form_data: Record<string, string>;
avatar_url: string; 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', method: 'POST',
headers: getAuthHeaders(), headers: {
body: JSON.stringify({ file_name: fileName }), ...(token && { Authorization: `Bearer ${token}` }),
},
body: formData,
}); });
return response.json(); return response.json();
} }
// 更新头像URL // 更新头像URL用于外部URL场景区别于文件直传
export async function updateAvatarUrl(avatarUrl: string): Promise<ApiResponse<{ export async function updateAvatarUrl(avatarUrl: string): Promise<ApiResponse<{
id: number; id: number;
username: string; username: string;
@@ -313,3 +358,79 @@ export async function resetYggdrasilPassword(): Promise<ApiResponse<{
return response.json(); 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();
}